@fire-spark/core 0.4.0 → 0.5.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 +214 -65
  2. package/dist/index.js +965 -925
  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, discountRedeems, groupPaymentRedeems, isDeliveryFee, isPaymentError, isPaymentRedeem, isRealPaymentMethod, isRealPaymentMethod as isRealPaymentMethod$1, isTenderSettled, isTipFee, liveTenders, memoryPaymentStorage, missingDeviceFields, orderPaymentRedeems, orderPaymentRedeems as orderPaymentRedeems$1, paymentRedeemsCoverTotal, resolvePaymentPolicy, resolveProviderEnvironment, roundTo, 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,49 +3512,91 @@ 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
- 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);
3472
- };
3473
3515
  /**
3474
- * Room left for order-level payment redeems: the D18 cap minus what the
3475
- * 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.
3476
3519
  */
3477
- const orderRedeemCap = (order) => {
3478
- const used = orderPaymentRedeems({ lines: order.lines }).reduce((sum, item) => sum + (item.amount ?? 0), 0);
3479
- const decimals = currencyDecimals(order.totals.currency);
3480
- return Math.max(roundTo(redemptionCap(order) - used, decimals), 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);
3525
+ }, moneyZero());
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
+ };
3481
3556
  };
3482
- /**
3483
- * Trims payment redeems in application order so their sum fits the cap.
3484
- * Discount redeems are left untouched.
3485
- */
3486
- const clampPaymentRedeems = (redeem, cap, currency) => {
3487
- const decimals = currencyDecimals(currency);
3488
- let left = cap;
3489
- return redeem.flatMap((item) => {
3490
- if (!isPaymentRedeem(item)) return [item];
3491
- const amount = item.amount ?? 0;
3492
- const fits = Math.max(roundTo(Math.min(amount, left), decimals), 0);
3493
- left = roundTo(left - fits, decimals);
3494
- if (fits <= 0) return [];
3495
- if (fits + amountTolerance(currency) >= amount) return [item];
3557
+ const keepFitted = (items, fitted) => {
3558
+ return items.flatMap((item, index) => {
3559
+ const fits = fitted[index] ?? moneyZero();
3560
+ if (fits.lte(0)) return [];
3561
+ if (fits.eq(item.amount)) return [item];
3496
3562
  return [{
3497
3563
  ...item,
3498
- amount: fits
3564
+ amount: moneyNumber(fits)
3499
3565
  }];
3500
3566
  });
3501
3567
  };
3502
3568
  /**
3503
- * One REDEMPTION tender per `payment_id` of the payment redeems (D15,
3504
- * 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.
3505
3597
  */
3506
3598
  const redemptionTenderInputs = (redeem, providers = /* @__PURE__ */ new Map()) => {
3507
- return groupPaymentRedeems(redeem).map(({ payment_id, details }) => {
3599
+ return groupRedeems(redeem).map(({ payment_id, details }) => {
3508
3600
  const fallback = DEFAULT_REDEMPTION_PROVIDER;
3509
3601
  const t1 = {
3510
3602
  method: "REDEMPTION",
@@ -3884,14 +3976,15 @@ const chargedTenders = (intents) => {
3884
3976
  };
3885
3977
  /** Settled tenders plus PENDING `confirm_after_inject`, order currency. */
3886
3978
  const coveredAmount = (intents) => {
3887
- return chargedTenders(intents).reduce((sum, tender) => {
3888
- return sum + tender.amount.total;
3889
- }, 0);
3979
+ return moneyNumber(chargedTenders(intents).reduce((sum, tender) => {
3980
+ return sum.plus(tender.amount.total);
3981
+ }, moneyZero()));
3890
3982
  };
3891
3983
  const remainingAmount = (order) => {
3892
- const decimals = currencyDecimals(order.totals.currency);
3893
- const left = order.totals.total - coveredAmount(order.payment_intents);
3894
- return Math.max(roundTo(left, decimals), 0);
3984
+ const covered = money(coveredAmount(order.payment_intents));
3985
+ const left = money(order.totals.total).minus(covered);
3986
+ if (left.lt(0)) return 0;
3987
+ return moneyNumber(left);
3895
3988
  };
3896
3989
  const isPlaced = (order) => order.status !== "OPEN";
3897
3990
  /** Orchestrator failures carry the charge-currency amount, not the order's. */
@@ -3909,8 +4002,7 @@ const partialOf = (result) => {
3909
4002
  const isPartialApproval = (result, tender, ctx) => {
3910
4003
  if (result.status !== "PAID") return false;
3911
4004
  if (ctx.amount.currency !== tender.amount.currency) return false;
3912
- const tolerance = amountTolerance(tender.amount.currency);
3913
- return result.amount + tolerance < tender.amount.total;
4005
+ return money(result.amount).lt(tender.amount.total);
3914
4006
  };
3915
4007
  const toFailure = (error) => {
3916
4008
  const message = error instanceof Error ? error.message : "Terminal failed";
@@ -4021,12 +4113,9 @@ var PaymentSession = class {
4021
4113
  const conversion = await this.chargeAmount(payment, amount, server);
4022
4114
  const config = record(payment.config);
4023
4115
  const methodConfig = record(payment.method.config);
4024
- const share = order.totals.total > 0 ? amount / order.totals.total : 1;
4025
- const decimals = currencyDecimals(order.totals.currency);
4026
- const taxes = taxBreakdown(order.lines, {
4027
- share,
4028
- decimals
4029
- });
4116
+ const total = order.totals.total;
4117
+ const share = moneyNumber(total > 0 ? money(amount).div(total) : money(1));
4118
+ const taxes = taxBreakdown(order.lines, { share });
4030
4119
  const info = order.customer.customer_info;
4031
4120
  const customer = {
4032
4121
  document: info.document_number ?? null,
@@ -4657,109 +4746,38 @@ const collectRecommendations = (config) => {
4657
4746
  return products;
4658
4747
  };
4659
4748
  //#endregion
4660
- //#region src/order/totals.ts
4661
- /** Sum root line group totals into order totals (Big.js). */
4662
- const recomputeTotals = (lines, currency = "USD") => {
4663
- let subtotal = new Big(0);
4664
- let discountTotal = new Big(0);
4665
- let taxTotal = new Big(0);
4666
- let total = new Big(0);
4667
- for (const line of lines) {
4668
- if (line.parent_uid != null) continue;
4669
- subtotal = subtotal.plus(line.group_subtotal);
4670
- discountTotal = discountTotal.plus(line.group_discount);
4671
- taxTotal = taxTotal.plus(line.group_tax_total);
4672
- total = total.plus(line.group_total);
4673
- }
4674
- let taxRate = 0;
4675
- let discountRate = 0;
4676
- if (subtotal.gt(0)) {
4677
- taxRate = taxTotal.div(subtotal).toNumber();
4678
- discountRate = discountTotal.div(subtotal).toNumber();
4679
- }
4680
- const discount_total = discountTotal.toNumber();
4681
- const tax_total = taxTotal.toNumber();
4682
- const b1 = {
4683
- currency,
4684
- subtotal: subtotal.toNumber()
4685
- };
4686
- const b2 = {
4687
- discount_rate: discountRate,
4688
- discount_total
4689
- };
4690
- const b3 = {
4691
- tax_rate: taxRate,
4692
- tax_total,
4693
- total: total.toNumber()
4694
- };
4695
- return {
4696
- ...b1,
4697
- ...b2,
4698
- ...b3
4699
- };
4700
- };
4701
- const emptyTotals = (currency = "USD") => {
4702
- const b1 = {
4703
- currency,
4704
- subtotal: 0,
4705
- discount_rate: 0,
4706
- discount_total: 0
4707
- };
4708
- const b2 = {
4709
- tax_rate: 0,
4710
- tax_total: 0,
4711
- total: 0
4712
- };
4713
- return {
4714
- ...b1,
4715
- ...b2
4716
- };
4717
- };
4718
- //#endregion
4719
4749
  //#region src/order/redemptions.ts
4720
- const ORDER_KEY = "ORDER";
4721
- const buildPaymentRedeem = (input) => {
4722
- const { reward_id, quantity, payment_id, amount } = input;
4723
- if (amount === void 0 || !(amount > 0)) throw new Error("Redeem with payment_id requires amount > 0");
4724
- const redeem = {
4725
- reward_id,
4726
- quantity,
4727
- payment_id,
4728
- amount
4729
- };
4730
- if (input.expires_at !== void 0) redeem.expires_at = input.expires_at;
4731
- return redeem;
4732
- };
4733
4750
  /** Same rules as `vOrders.redeem()`; throws on invalid input. */
4734
4751
  const buildRedeem = (input) => {
4735
- const { reward_id, quantity, discount_rate, discount_amount } = input;
4752
+ const { reward_id, quantity, payment_id, amount } = input;
4736
4753
  const { before_taxes, expires_at } = input;
4737
- 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");
4738
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");
4739
4758
  if (expires_at !== void 0 && Number.isNaN(Date.parse(expires_at))) throw new Error("Redeem expires_at must be an ISO datetime");
4740
- if (input.payment_id) return buildPaymentRedeem(input);
4741
- if (discount_rate === void 0 && discount_amount === void 0) throw new Error("Redeem requires discount_rate or discount_amount");
4742
- const rate = discount_rate ?? 0;
4743
- if (!(rate >= 0 && rate <= 1)) throw new Error("Redeem discount_rate must be between 0 and 1");
4744
- if (discount_amount !== void 0 && !(discount_amount > 0)) throw new Error("Redeem discount_amount must be > 0");
4745
4759
  const redeem = {
4746
4760
  reward_id,
4747
- quantity
4761
+ quantity,
4762
+ payment_id,
4763
+ amount
4748
4764
  };
4749
- if (discount_rate !== void 0) redeem.discount_rate = discount_rate;
4750
- if (discount_amount !== void 0) redeem.discount_amount = discount_amount;
4751
4765
  if (before_taxes !== void 0) redeem.before_taxes = before_taxes;
4752
4766
  if (expires_at !== void 0) redeem.expires_at = expires_at;
4753
4767
  return redeem;
4754
4768
  };
4755
- /** 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
+ */
4756
4773
  const keepUnits = (item, rest) => {
4757
- if (!isPaymentRedeem(item) || rest === item.quantity) return {
4774
+ if (rest === item.quantity) return {
4758
4775
  ...item,
4759
4776
  quantity: rest
4760
4777
  };
4761
- const ratio = new Big(rest).div(item.quantity);
4762
- 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();
4763
4781
  return {
4764
4782
  ...item,
4765
4783
  quantity: rest,
@@ -4767,8 +4785,8 @@ const keepUnits = (item, rest) => {
4767
4785
  };
4768
4786
  };
4769
4787
  /**
4770
- * Takes `quantity` units of `reward_id` off the latest entries first, so the
4771
- * 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.
4772
4790
  */
4773
4791
  const subtractRedeem = (items, reward_id, quantity) => {
4774
4792
  if (!Number.isInteger(quantity) || quantity < 1) throw new Error("Redeem quantity must be an integer ≥ 1");
@@ -4787,143 +4805,43 @@ const subtractRedeem = (items, reward_id, quantity) => {
4787
4805
  }
4788
4806
  return kept;
4789
4807
  };
4790
- const redemptionTargetKey = (target) => {
4791
- if (target.target === "ORDER") return ORDER_KEY;
4792
- return `LINE:${target.target_id ?? ""}`;
4793
- };
4794
4808
  /**
4795
- * Only root lines own redeems; child-line redeems are never applied.
4796
- * 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.
4797
4812
  */
4798
- const redeemPools = (lines, redeem) => {
4799
- const pools = /* @__PURE__ */ new Map();
4800
- for (const line of lines) {
4801
- const items = discountRedeems(line.redeem ?? []);
4802
- if (line.parent_uid != null || items.length === 0) continue;
4803
- const target = {
4804
- target: "LINE",
4805
- target_id: line.uid
4806
- };
4807
- const pool = items.map((item) => ({
4808
- ...target,
4809
- redeem: item
4810
- }));
4811
- pools.set(redemptionTargetKey(target), pool);
4812
- }
4813
- const order = { target: "ORDER" };
4814
- const pool = discountRedeems(redeem).map((item) => ({
4815
- ...order,
4816
- redeem: item
4817
- }));
4818
- pools.set(ORDER_KEY, pool);
4819
- 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));
4820
4822
  };
4821
4823
  /**
4822
- * Pairs the k-th step of a target with the k-th redeem of that target, in
4823
- * step order. Steps without a matching redeem are dropped; redeems without a
4824
- * 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.
4825
4826
  */
4826
- const sequenceRedeems = (lines, redeem, steps) => {
4827
- const pools = redeemPools(lines, redeem);
4828
- const cursors = /* @__PURE__ */ new Map();
4829
- const sequence = [];
4830
- for (const step of steps) {
4831
- const key = redemptionTargetKey(step);
4832
- const at = cursors.get(key) ?? 0;
4833
- const next = pools.get(key)?.[at];
4834
- if (!next || next.redeem.reward_id !== step.reward_id) continue;
4835
- cursors.set(key, at + 1);
4836
- sequence.push(next);
4837
- }
4838
- for (const [key, pool] of pools) sequence.push(...pool.slice(cursors.get(key) ?? 0));
4839
- return sequence;
4840
- };
4841
- const minBig = (a, b) => {
4842
- return a.lt(b) ? a : b;
4843
- };
4844
- const rateOf = (discount, base) => {
4845
- if (base.lte(0)) return 0;
4846
- return minBig(discount.div(base), new Big(1)).toNumber();
4847
- };
4848
- const snapshot = (roots, quantity) => {
4849
- let subtotal = new Big(0);
4850
- let total = new Big(0);
4851
- let discount = new Big(0);
4852
- for (const root of roots) {
4853
- subtotal = subtotal.plus(root.subtotal);
4854
- total = total.plus(root.total);
4855
- discount = discount.plus(root.discount);
4856
- }
4857
- const b1 = {
4858
- value: total.toNumber(),
4859
- quantity
4860
- };
4861
- const b2 = { discount_amount: discount.toNumber() };
4862
- const b3 = { discount_rate: rateOf(discount, subtotal) };
4863
- return {
4864
- ...b1,
4865
- ...b2,
4866
- ...b3
4867
- };
4868
- };
4869
- const netOf = (root) => {
4870
- const net = root.total.minus(root.tax);
4871
- return net.gt(0) ? net : new Big(0);
4872
- };
4873
- /** What a redeem can still discount on a root: pre-tax or with taxes. */
4874
- const baseOf = (root, beforeTaxes) => {
4875
- return beforeTaxes ? netOf(root) : root.total;
4876
- };
4877
- /** Before taxes, taxes shrink with the pre-tax value they were charged on. */
4878
- const discountRoot = (root, amount, beforeTaxes) => {
4879
- if (amount.lte(0)) return;
4880
- root.discount = root.discount.plus(amount);
4881
- if (!beforeTaxes) {
4882
- root.total = root.total.minus(amount);
4883
- return;
4884
- }
4885
- const net = netOf(root);
4886
- const nextNet = net.minus(amount);
4887
- root.tax = root.tax.times(nextNet).div(net);
4888
- root.total = nextNet.plus(root.tax);
4889
- };
4890
- /** Splits an order discount across roots by what each can still discount. */
4891
- const discountOrder = (roots, amount, beforeTaxes) => {
4892
- const bases = roots.map((root) => baseOf(root, beforeTaxes));
4893
- const sum = bases.reduce((acc, base) => acc.plus(base), new Big(0));
4894
- if (sum.lte(0) || amount.lte(0)) return;
4895
- let last = -1;
4896
- bases.forEach((base, index) => {
4897
- if (base.gt(0)) last = index;
4898
- });
4899
- let rest = amount;
4900
- roots.forEach((root, index) => {
4901
- const base = bases[index];
4902
- if (base.lte(0)) return;
4903
- const share = amount.times(base).div(sum);
4904
- const part = minBig(index === last ? rest : share, base);
4905
- rest = rest.minus(part);
4906
- discountRoot(root, part, beforeTaxes);
4907
- });
4827
+ const orderPayableTotal = (order) => {
4828
+ const rest = new Big(order.totals.total).minus(orderRedeemedTotal(order));
4829
+ return rest.gt(0) ? rest.toNumber() : 0;
4908
4830
  };
4909
4831
  /**
4910
- * Rate compounds once per reward unit over the remaining value; amount is
4911
- * 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.
4912
4834
  */
4913
- const redeemDiscount = (redeem, remaining) => {
4914
- const { quantity, discount_rate, discount_amount } = redeem;
4915
- let cap = null;
4916
- if (discount_amount !== void 0) cap = new Big(discount_amount).times(quantity);
4917
- if (discount_rate === void 0) return cap ?? new Big(0);
4918
- const kept = new Big(1).minus(discount_rate).pow(quantity);
4919
- const byRate = remaining.times(new Big(1).minus(kept));
4920
- 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;
4921
4838
  };
4922
4839
  /**
4923
- * Root group money rebuilt from the line itself plus its children, i.e. the
4924
- * 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.
4925
4843
  */
4926
- const stripRedemptionLines = (lines) => {
4844
+ const rebuildRootGroups = (lines) => {
4927
4845
  const children = /* @__PURE__ */ new Map();
4928
4846
  for (const line of lines) {
4929
4847
  if (line.parent_uid == null) continue;
@@ -4972,111 +4890,162 @@ const stripRedemptionLines = (lines) => {
4972
4890
  };
4973
4891
  });
4974
4892
  };
4975
- const withRootMoney = (line, money) => {
4976
- const qty = new Big(line.quantity);
4977
- const discount = new Big(line.group_discount).plus(money.discount);
4978
- const u1 = { unit_group_discount: discount.div(qty).toNumber() };
4979
- const u2 = { unit_group_tax_total: money.tax.div(qty).toNumber() };
4980
- const u3 = { unit_group_total: money.total.div(qty).toNumber() };
4981
- const g1 = { group_discount: discount.toNumber() };
4982
- const g2 = { group_tax_total: money.tax.toNumber() };
4983
- 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) };
4984
4903
  return {
4985
- ...line,
4986
- ...u1,
4987
- ...u2,
4988
- ...u3,
4989
- ...g1,
4990
- ...g2,
4991
- ...g3
4904
+ ...s1,
4905
+ ...s2,
4906
+ discount_rate: rate.toNumber()
4992
4907
  };
4993
4908
  };
4994
4909
  /**
4995
- * Replays every redeem in persisted step order over the root lines' group
4996
- * money. Line redeems discount their root; order redeems are prorated over
4997
- * 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.
4998
4916
  */
4999
- const replayRedemptions = (lines, redeem, steps) => {
5000
- const base = stripRedemptionLines(lines);
5001
- const sequence = sequenceRedeems(base, redeem, steps);
5002
- const moneyByUid = /* @__PURE__ */ new Map();
5003
- for (const line of base) {
5004
- if (line.parent_uid != null) continue;
5005
- const b1 = { quantity: line.quantity };
5006
- const b2 = { subtotal: new Big(line.group_subtotal) };
5007
- const b3 = { tax: new Big(line.group_tax_total) };
5008
- const b4 = {
5009
- total: new Big(line.group_total),
5010
- discount: new Big(0)
5011
- };
5012
- moneyByUid.set(line.uid, {
5013
- ...b1,
5014
- ...b2,
5015
- ...b3,
5016
- ...b4
5017
- });
5018
- }
5019
- const roots = [...moneyByUid.values()];
5020
- const redemption_steps = sequence.map((item) => {
5021
- const id = item.target_id;
5022
- const root = id ? moneyByUid.get(id) : void 0;
5023
- const beforeTaxes = item.redeem.before_taxes ?? true;
5024
- const before = snapshot(roots, 1);
5025
- const targetBefore = root ? snapshot([root], root.quantity) : void 0;
5026
- let left = new Big(0);
5027
- if (root) left = baseOf(root, beforeTaxes);
5028
- else for (const r of roots) left = left.plus(baseOf(r, beforeTaxes));
5029
- const raw = redeemDiscount(item.redeem, left);
5030
- const discount = minBig(raw, left);
5031
- if (root) discountRoot(root, discount, beforeTaxes);
5032
- else discountOrder(roots, discount, beforeTaxes);
5033
- const action = discount.gt(0) ? "UPDATE" : "READ";
5034
- const { reward_id, quantity } = item.redeem;
5035
- const b1 = {
5036
- reward_id,
5037
- reward_quantity: quantity,
5038
- 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
5039
4934
  };
5040
- const b2 = id ? { target_id: id } : {};
5041
- const b3 = {
5042
- action,
5043
- discount_amount: discount.toNumber()
4935
+ const s2 = { payment_id: item.payment_id };
4936
+ const s3 = {
4937
+ before_taxes: item.before_taxes ?? true,
4938
+ action
5044
4939
  };
5045
- const b4 = {
4940
+ const s4 = { discount_amount: moneyNumber(fits) };
4941
+ const s5 = {
5046
4942
  before,
5047
- after: snapshot(roots, 1)
5048
- };
5049
- let b5 = {};
5050
- if (root) b5 = {
5051
- target_before: targetBefore,
5052
- target_after: snapshot([root], root.quantity)
4943
+ after: stateOf(whole)
5053
4944
  };
5054
4945
  return {
5055
- ...b1,
5056
- ...b2,
5057
- ...b3,
5058
- ...b4,
5059
- ...b5
4946
+ ...s1,
4947
+ ...s2,
4948
+ ...s3,
4949
+ ...s4,
4950
+ ...s5,
4951
+ targetBefore
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
5060
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
+ });
5061
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
+ };
5062
5028
  return {
5063
- lines: base.map((line) => {
5064
- const money = moneyByUid.get(line.uid);
5065
- if (!money || money.discount.eq(0)) return line;
5066
- return withRootMoney(line, money);
5067
- }),
5068
- redemption_steps
5029
+ ...b1,
5030
+ ...b2,
5031
+ ...b3
5069
5032
  };
5070
5033
  };
5071
- /** Discounted lines, steps and totals, keeping step order. */
5072
- const recomputeRedemptions = (input) => {
5073
- const { redeem, currency } = input;
5074
- const steps = input.redemption_steps;
5075
- 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
+ };
5076
5046
  return {
5077
- lines,
5078
- redemption_steps,
5079
- totals: recomputeTotals(lines, currency)
5047
+ ...b1,
5048
+ ...b2
5080
5049
  };
5081
5050
  };
5082
5051
  //#endregion
@@ -5084,9 +5053,9 @@ const recomputeRedemptions = (input) => {
5084
5053
  const cloneOrder = (order) => {
5085
5054
  return structuredClone(order);
5086
5055
  };
5087
- const redeemSignature$1 = (items) => {
5056
+ const redeemSignature = (items) => {
5088
5057
  return items.map((item) => {
5089
- 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 ?? ""}`}`;
5090
5059
  }).join("|");
5091
5060
  };
5092
5061
  const buildHttpOptions = (options) => {
@@ -5147,14 +5116,14 @@ const buildInsertFromSource = (options, source) => {
5147
5116
  if (options.scope?.brand_id !== void 0) brand_id = optionalBrandId(options.scope.brand_id);
5148
5117
  let customer = source.customer;
5149
5118
  if (options.customer) customer = buildCustomerSnapshot(options.customer);
5150
- const sourceLines = structuredClone(source.lines);
5151
5119
  const is_anonymous = options.isAnonymous ?? source.is_anonymous;
5152
5120
  const redeem = structuredClone(source.redeem ?? []);
5153
- const { lines, redemption_steps, totals } = recomputeRedemptions({
5154
- lines: sourceLines,
5155
- redeem,
5156
- redemption_steps: source.redemption_steps ?? [],
5157
- 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
5158
5127
  });
5159
5128
  const b1 = {
5160
5129
  id: orderId,
@@ -5500,20 +5469,20 @@ var Order = class Order {
5500
5469
  });
5501
5470
  }
5502
5471
  /**
5503
- * Appends a redeem to `line_uid` (root line) or to the order. Steps keep
5504
- * the order in which redeems were applied. With `payment_id` (voucher,
5505
- * balance…) it is not a discount: `amount` is the total of the whole
5506
- * entry (every unit, not per unit) and `quantity` is informational.
5507
- * Every redeem with the same `payment_id` is paid by one REDEMPTION
5508
- * tender whose `details.rewards[]` lists them; their summed amount is
5509
- * 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).
5510
5480
  */
5511
5481
  applyRedeem(input) {
5512
5482
  if (this.deleted) return;
5513
5483
  const { line_uid, provider, ...rest } = input;
5514
5484
  const redeem = buildRedeem(rest);
5515
- if (isPaymentRedeem(redeem) && line_uid !== void 0) throw new Error("Payment redeems apply to the whole order");
5516
- if (redeem.payment_id && provider) this.redemptionProviders.set(redeem.payment_id, provider);
5485
+ if (provider) this.redemptionProviders.set(redeem.payment_id, provider);
5517
5486
  if (line_uid === void 0) {
5518
5487
  this.commitLines({ redeem: [...this.appliedRedeem(), redeem] });
5519
5488
  return;
@@ -5532,8 +5501,8 @@ var Order = class Order {
5532
5501
  /**
5533
5502
  * Removes `quantity` units of `reward_id` from `line_uid` or the order,
5534
5503
  * latest applied first. Removing more units than applied clears them all
5535
- * (and the REDEMPTION tender of a payment redeem). A payment redeem that
5536
- * 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`.
5537
5506
  */
5538
5507
  removeRedeem(input) {
5539
5508
  if (this.deleted) return;
@@ -5689,10 +5658,10 @@ var Order = class Order {
5689
5658
  lines: draft.lines,
5690
5659
  redeem: draft.redeem ?? []
5691
5660
  };
5692
- const steps = draft.redemption_steps ?? [];
5661
+ const redemption_steps = redemptionSteps(draft);
5693
5662
  const b5 = {
5694
5663
  totals: draft.totals,
5695
- redemption_steps: steps
5664
+ redemption_steps
5696
5665
  };
5697
5666
  const body = {
5698
5667
  ...b1,
@@ -5704,15 +5673,16 @@ var Order = class Order {
5704
5673
  await services.orders.validate(body, this.http);
5705
5674
  }
5706
5675
  /**
5707
- * Tenders of the next intent: real ones plus one per payment redeem
5708
- * `payment_id`, on the order or on a line. When the payment redeems
5709
- * 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.
5710
5680
  */
5711
5681
  getTenders(payment) {
5712
- const redeem = orderPaymentRedeems$1(this.draft);
5682
+ const redeem = cappedRedeems(this.draft);
5713
5683
  const providers = this.redemptionProviders;
5714
5684
  const redemption = redemptionTenderInputs(redeem, providers);
5715
- if (paymentRedeemsCoverTotal(redeem, this.draft.totals)) return redemption;
5685
+ if (redeemsCoverTotal(redeem, this.draft.totals)) return redemption;
5716
5686
  const simple = payment ? simpleTenderInput(payment) : null;
5717
5687
  return [...simple ? [simple] : this.realTenders ?? [], ...redemption];
5718
5688
  }
@@ -5735,6 +5705,13 @@ var Order = class Order {
5735
5705
  this.applyServerOrder(next);
5736
5706
  return this.getOrder();
5737
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
+ }
5738
5715
  async syncPaymentIntent(tenders) {
5739
5716
  await this.persist();
5740
5717
  await this.flush.settle();
@@ -5751,7 +5728,7 @@ var Order = class Order {
5751
5728
  }
5752
5729
  /** Keeps the applied redeems while the server holds the same capped ones. */
5753
5730
  replaceDraft(next) {
5754
- 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;
5755
5732
  this.draft = next;
5756
5733
  }
5757
5734
  appliedRedeem() {
@@ -5761,10 +5738,14 @@ var Order = class Order {
5761
5738
  * Places the order with a DRAFT intent: `intent_id` as is, else the
5762
5739
  * tenders of `getTenders(input.payment)` saved first (simple flow: one
5763
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.
5764
5744
  */
5765
5745
  async placeOrder(input = {}) {
5766
5746
  await this.persist();
5767
5747
  await this.flush.settle();
5748
+ if (!input.intent_id) this.assertPayable(input.payment);
5768
5749
  await this.validateOrder();
5769
5750
  let body = input;
5770
5751
  if (!input.intent_id) {
@@ -5879,29 +5860,28 @@ var Order = class Order {
5879
5860
  runPaymentTerminal(payment, key) {
5880
5861
  return this.payments.runTerminal(payment, key);
5881
5862
  }
5882
- /** 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
+ */
5883
5867
  commitLines(patch) {
5884
- const patched = patch.lines ?? this.draft.lines;
5868
+ const patched = rebuildRootGroups(patch.lines ?? this.draft.lines);
5885
5869
  const applied = patch.redeem ?? this.appliedRedeem();
5886
- const steps = this.draft.redemption_steps ?? [];
5887
- const currency = this.draft.totals.currency;
5888
- const { lines, redemption_steps, totals } = recomputeRedemptions({
5870
+ const totals = recomputeTotals(patched, this.draft.totals.currency);
5871
+ const capped = capRedeems({
5889
5872
  lines: patched,
5890
- redeem: applied,
5891
- redemption_steps: steps,
5892
- currency
5873
+ totals,
5874
+ redeem: applied
5893
5875
  });
5894
- const redeem = clampPaymentRedeems(applied, orderRedeemCap({
5895
- lines,
5896
- totals
5897
- }), currency);
5876
+ const { lines, redeem } = capped;
5877
+ const redemption_steps = redemptionSteps(capped);
5898
5878
  this.requestedRedeem = applied;
5899
5879
  this.draft = {
5900
5880
  ...this.draft,
5901
5881
  lines,
5902
5882
  redeem,
5903
- redemption_steps,
5904
- totals
5883
+ totals,
5884
+ redemption_steps
5905
5885
  };
5906
5886
  this.scheduleAfterMutate();
5907
5887
  }
@@ -6453,56 +6433,70 @@ const expiredIssues = (redeem, now) => {
6453
6433
  });
6454
6434
  return issues;
6455
6435
  };
6456
- const redeemSignature = (items) => {
6457
- return items.map((item) => `${item.reward_id}:${item.quantity}`).join(",");
6458
- };
6459
- const stepSignature = (steps) => {
6460
- 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;
6461
6443
  };
6462
6444
  /**
6463
- * Every target's discount redeems must have one step each, in the same
6464
- * 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`).
6465
6448
  */
6466
- const checkRedemptionTargets = (lines, redeem, steps, issues, lineIssues) => {
6467
- const byTarget = /* @__PURE__ */ new Map();
6468
- for (const step of steps) {
6469
- const key = redemptionTargetKey(step);
6470
- byTarget.set(key, [...byTarget.get(key) ?? [], step]);
6471
- }
6472
- const check = (key, items) => {
6473
- const targetSteps = byTarget.get(key) ?? [];
6474
- byTarget.delete(key);
6475
- const expected = redeemSignature(discountRedeems(items));
6476
- const actual = stepSignature(targetSteps);
6477
- if (expected === actual) return null;
6478
- return {
6479
- code: "REDEMPTION_STEPS_MISMATCH",
6480
- field: "redemption_steps",
6481
- expected,
6482
- actual
6483
- };
6484
- };
6485
- const orderIssue = check(redemptionTargetKey({ target: "ORDER" }), redeem);
6486
- if (orderIssue) issues.push(orderIssue);
6487
- for (const line of lines) {
6488
- const lineIssue = check(redemptionTargetKey({
6489
- target: "LINE",
6490
- target_id: line.uid
6491
- }), line.redeem ?? []);
6492
- if (lineIssue) pushLineIssue(lineIssues, line.uid, lineIssue);
6493
- }
6494
- for (const orphan of byTarget.values()) {
6495
- const b1 = { code: "REDEMPTION_STEPS_MISMATCH" };
6496
- const b2 = {
6497
- field: "redemption_steps.target_id",
6498
- expected: ""
6499
- };
6500
- issues.push({
6501
- ...b1,
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`
6486
+ };
6487
+ const b2 = { expected: bigStr(expected?.toNumber() ?? 0) };
6488
+ issues.push({
6489
+ ...b1,
6502
6490
  ...b2,
6503
- actual: stepSignature(orphan)
6491
+ actual: bigStr(item.amount)
6504
6492
  });
6505
- }
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(",");
6506
6500
  };
6507
6501
  const STATE_FIELDS = [
6508
6502
  "value",
@@ -6520,37 +6514,39 @@ const flattenState = (prefix, state, out) => {
6520
6514
  for (const field of STATE_FIELDS) out[`${prefix}.${field}`] = state ? bigStr(state[field]) : "";
6521
6515
  };
6522
6516
  const flattenStep = (step) => {
6523
- const b1 = { reward_id: step.reward_id };
6524
- const b2 = { reward_quantity: String(step.reward_quantity) };
6525
- const b3 = {
6526
- target: step.target,
6527
- target_id: step.target_id ?? ""
6517
+ const b1 = {
6518
+ action: step.action,
6519
+ before_taxes: String(step.before_taxes)
6528
6520
  };
6529
- const b4 = { action: step.action };
6530
- const b5 = { discount_amount: bigStr(step.discount_amount) };
6521
+ const b2 = { discount_amount: bigStr(step.discount_amount) };
6531
6522
  const out = {
6532
6523
  ...b1,
6533
- ...b2,
6534
- ...b3,
6535
- ...b4,
6536
- ...b5
6524
+ ...b2
6537
6525
  };
6538
6526
  for (const key of STATE_KEYS) flattenState(key, step[key], out);
6539
6527
  return out;
6540
6528
  };
6541
- /** Replayed steps must equal the submitted ones field by field. */
6542
- const compareRedemptionSteps = (submitted, expected, issues) => {
6543
- if (submitted.length !== expected.length) {
6544
- const b1 = { code: "REDEMPTION_MISMATCH" };
6545
- const b2 = { field: "redemption_steps" };
6546
- 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
+ };
6547
6544
  issues.push({
6548
6545
  ...b1,
6549
6546
  ...b2,
6550
- ...b3,
6551
- actual: String(submitted.length)
6547
+ actual: actualSignature
6552
6548
  });
6553
- return;
6549
+ return issues;
6554
6550
  }
6555
6551
  submitted.forEach((step, index) => {
6556
6552
  const actual = flattenStep(step);
@@ -6570,22 +6566,36 @@ const compareRedemptionSteps = (submitted, expected, issues) => {
6570
6566
  });
6571
6567
  }
6572
6568
  });
6569
+ return issues;
6573
6570
  };
6574
6571
  const validateRedemptions = (input, issues, lineIssues) => {
6575
6572
  const redeem = input.redeem ?? [];
6576
- const steps = input.redemptionSteps ?? [];
6577
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);
6578
6580
  issues.push(...expiredIssues(redeem, now));
6579
- for (const line of input.lines) {
6580
- 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);
6581
6585
  for (const issue of expired) pushLineIssue(lineIssues, line.uid, issue);
6582
- }
6583
- checkRedemptionTargets(input.lines, redeem, steps, issues, lineIssues);
6584
- const replayed = replayRedemptions(input.lines, redeem, steps);
6585
- compareRedemptionSteps(steps, replayed.redemption_steps, issues);
6586
- 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));
6587
6597
  };
6588
- const REDEEMED_GROUP_FIELDS = [
6598
+ const ROOT_GROUP_FIELDS = [
6589
6599
  ["group_discount", "DISCOUNT_MISMATCH"],
6590
6600
  ["unit_group_discount", "DISCOUNT_MISMATCH"],
6591
6601
  ["group_tax_total", "TAX_MISMATCH"],
@@ -6593,14 +6603,17 @@ const REDEEMED_GROUP_FIELDS = [
6593
6603
  ["group_total", "PRICE_MISMATCH"],
6594
6604
  ["unit_group_total", "PRICE_MISMATCH"]
6595
6605
  ];
6596
- /** Root group money must carry exactly the replayed redemption discounts. */
6597
- 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) => {
6598
6611
  const byUid = new Map(expected.map((line) => [line.uid, line]));
6599
6612
  for (const line of submitted) {
6600
6613
  if (line.parent_uid != null) continue;
6601
6614
  const wanted = byUid.get(line.uid);
6602
6615
  if (!wanted) continue;
6603
- for (const [field, code] of REDEEMED_GROUP_FIELDS) {
6616
+ for (const [field, code] of ROOT_GROUP_FIELDS) {
6604
6617
  if (bigEq(line[field], wanted[field])) continue;
6605
6618
  const b1 = {
6606
6619
  code,
@@ -6625,7 +6638,7 @@ const validateOrderAgainstMenu = (input) => {
6625
6638
  lines: {}
6626
6639
  };
6627
6640
  if (!input.menuActive) issues.push({ code: "MENU_CLOSED" });
6628
- const menuLines = stripRedemptionLines(input.lines);
6641
+ const menuLines = rebuildRootGroups(input.lines);
6629
6642
  const roots = menuLines.filter((line) => line.parent_uid == null);
6630
6643
  if (roots.length === 0) {
6631
6644
  issues.push({ code: "EMPTY_ORDER" });
@@ -6645,9 +6658,9 @@ const validateOrderAgainstMenu = (input) => {
6645
6658
  }
6646
6659
  checkRootProductQuantities(roots, lookup, lineIssues);
6647
6660
  if (input.expectedFeeLines) compareExpectedFeeLines(input.lines, input.expectedFeeLines, lineIssues);
6648
- const redeemed = validateRedemptions(input, issues, lineIssues);
6649
- compareRedeemedRoots(input.lines, redeemed, lineIssues);
6650
- 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);
6651
6664
  for (const field of TOTALS_FIELDS) {
6652
6665
  if (bigEq(input.totals[field], expectedTotals[field])) continue;
6653
6666
  issues.push({
@@ -6959,391 +6972,8 @@ const verifyWebhookSignature = async (input) => {
6959
6972
  return safeEqual(received, await hmacHex(secret, body));
6960
6973
  };
6961
6974
  //#endregion
6962
- //#region src/rules/assert.ts
6963
- const assert = (value, message) => {
6964
- if (value) return;
6965
- throw new Error(message);
6966
- };
6967
- //#endregion
6968
- //#region src/rules/common.ts
6969
- const primitiveError = (primitive) => {
6970
- return `Condition primitive must be ${primitive}`;
6971
- };
6972
- const typeError = (type) => {
6973
- return `Condition type must be ${type}`;
6974
- };
6975
- const defaultConditionError = "Condition was not met";
6976
- const getLineByProductId = (lines, productId) => {
6977
- if (!productId) return void 0;
6978
- return lines?.find((line) => line.id === productId);
6979
- };
6980
- const countCartProducts = (lines, includeModifiers) => {
6981
- if (!lines?.length) return 0;
6982
- return lines.filter((line) => {
6983
- if (line.type === "FEE") return false;
6984
- if (includeModifiers) return true;
6985
- if (!(line.parent_uid == null)) return false;
6986
- return line.type === "PRODUCT" || line.type == null;
6987
- }).reduce((sum, line) => sum + line.quantity, 0);
6988
- };
6989
- const getLineTagValues = (lines, tag) => {
6990
- if (!tag) return [];
6991
- const values = [];
6992
- for (const line of lines ?? []) {
6993
- const value = line.tags?.[tag];
6994
- if (typeof value === "undefined") continue;
6995
- values.push(value);
6996
- }
6997
- return values;
6998
- };
6999
- //#endregion
7000
- //#region src/rules/booleanMatcher.ts
7001
- const matchBooleanOperator = (condition, matcher) => {
7002
- const { primitive, operator, value } = condition;
7003
- assert(primitive === "boolean", primitiveError("boolean"));
7004
- if (typeof matcher === "undefined") return false;
7005
- assert(typeof value === "boolean", "Invalid boolean value");
7006
- switch (operator) {
7007
- case "is": return matcher === value;
7008
- case "is not": return matcher !== value;
7009
- default: throw new Error(`Operator ${operator} not supported in boolean matcher`);
7010
- }
7011
- };
7012
- const isAnonymousCustomer = (condition, isAnonymous) => {
7013
- const { type, primitive, value } = condition;
7014
- const matcherType = "Customer is anonymous";
7015
- assert(primitive === "boolean", primitiveError("boolean"));
7016
- assert(type === matcherType, typeError(matcherType));
7017
- assert(value != null, "Invalid value");
7018
- return matchBooleanOperator(condition, isAnonymous ?? false);
7019
- };
7020
- const orderIncludesProductWithTag$3 = (condition, matcher) => {
7021
- const { type, primitive, value } = condition;
7022
- const matcherType = "Order includes product with tag";
7023
- assert(primitive === "boolean", primitiveError("boolean"));
7024
- assert(type === matcherType, typeError(matcherType));
7025
- assert(value != null, "Invalid value");
7026
- return matchBooleanOperator(condition, matcher);
7027
- };
7028
- const isAnonymousOrder = (condition, isAnonymous) => {
7029
- const { type, primitive, value } = condition;
7030
- const matcherType = "Is anonymous order";
7031
- assert(primitive === "boolean", primitiveError("boolean"));
7032
- assert(type === matcherType, typeError(matcherType));
7033
- assert(value != null, "Invalid value");
7034
- return matchBooleanOperator(condition, isAnonymous ?? false);
7035
- };
7036
- const paymentIsMixed = (condition, isMixed) => {
7037
- const { type, primitive, value } = condition;
7038
- const matcherType = "Payment is mixed";
7039
- assert(primitive === "boolean", primitiveError("boolean"));
7040
- assert(type === matcherType, typeError(matcherType));
7041
- assert(value != null, "Invalid value");
7042
- return matchBooleanOperator(condition, isMixed ?? false);
7043
- };
7044
- const paymentCaptured = (condition, captured) => {
7045
- const { type, primitive, value } = condition;
7046
- const matcherType = "Payment captured";
7047
- assert(primitive === "boolean", primitiveError("boolean"));
7048
- assert(type === matcherType, typeError(matcherType));
7049
- assert(value != null, "Invalid value");
7050
- return matchBooleanOperator(condition, captured ?? false);
7051
- };
7052
- //#endregion
7053
- //#region src/rules/currencyMatcher.ts
7054
- const matchCurrencyOperator = (condition, matcher) => {
7055
- const { primitive, operator, value } = condition;
7056
- assert(primitive === "currency", primitiveError("currency"));
7057
- if (typeof matcher === "undefined") return false;
7058
- assert(typeof value === "number", "Invalid currency value");
7059
- switch (operator) {
7060
- case "equal to": return matcher === value;
7061
- case "not equal to": return matcher !== value;
7062
- case "greater than": return matcher > value;
7063
- case "greater than or equal to": return matcher >= value;
7064
- case "less than": return matcher < value;
7065
- case "less than or equal to": return matcher <= value;
7066
- default: throw new Error(`Operator ${operator} not supported in currency matcher`);
7067
- }
7068
- };
7069
- const orderIncludesProductWithTag$2 = (condition, matcher) => {
7070
- const { type, primitive, value } = condition;
7071
- const matcherType = "Order includes product with tag";
7072
- assert(primitive === "currency", primitiveError("currency"));
7073
- assert(type === matcherType, typeError(matcherType));
7074
- assert(value != null, "Invalid value");
7075
- return matchCurrencyOperator(condition, matcher);
7076
- };
7077
- const orderTotal = (condition, total) => {
7078
- const { type, primitive, value } = condition;
7079
- const matcherType = "Order total";
7080
- assert(primitive === "currency", primitiveError("currency"));
7081
- assert(type === matcherType, typeError(matcherType));
7082
- assert(value != null, "Invalid value");
7083
- return matchCurrencyOperator(condition, total);
7084
- };
7085
- //#endregion
7086
- //#region src/rules/customMatcher.ts
7087
- const MULTI_OPERATORS = [
7088
- "is any of",
7089
- "is none of",
7090
- "is all of"
7091
- ];
7092
- const isMultiOperator = (operator) => {
7093
- return MULTI_OPERATORS.includes(operator);
7094
- };
7095
- const splitCsv = (value) => {
7096
- return value.split(",").map((part) => part.trim()).filter(Boolean);
7097
- };
7098
- const matchCustomEquality = (condition, matcher) => {
7099
- const { primitive, operator, value } = condition;
7100
- assert(primitive === "custom", primitiveError("custom"));
7101
- if (typeof matcher === "undefined") return false;
7102
- assert(typeof value === "string", "Invalid custom value");
7103
- switch (operator) {
7104
- case "is": return matcher === value;
7105
- case "is not": return matcher !== value;
7106
- default: throw new Error(`Operator ${operator} not supported in custom matcher`);
7107
- }
7108
- };
7109
- const matchCustomMulti = (condition, matcher) => {
7110
- const { primitive, operator, value } = condition;
7111
- assert(primitive === "custom", primitiveError("custom"));
7112
- if (typeof matcher === "undefined") return false;
7113
- assert(typeof value === "string", "Invalid custom value");
7114
- const allowed = splitCsv(value);
7115
- if (allowed.length === 0) return false;
7116
- const candidates = typeof matcher === "object" ? matcher : [matcher];
7117
- if (candidates.length === 0) return false;
7118
- if (operator === "is any of") return allowed.some((id) => candidates.includes(id));
7119
- if (operator === "is none of") return !allowed.some((id) => candidates.includes(id));
7120
- if (operator === "is all of") return allowed.every((id) => candidates.includes(id));
7121
- throw new Error(`Operator ${operator} not supported in custom matcher`);
7122
- };
7123
- /**
7124
- * The operator picks the arity: `Payment` is single-select in order rules and
7125
- * multi-select in fulfillment workflows. `matcher` may be a set (one id per
7126
- * tender): `is` matches when some id matches, `is not` when none does.
7127
- */
7128
- const matchCustom = (condition, matcher) => {
7129
- if (isMultiOperator(condition.operator)) return matchCustomMulti(condition, matcher);
7130
- if (typeof matcher === "undefined" || typeof matcher === "string") return matchCustomEquality(condition, matcher);
7131
- if (matcher.length === 0) return false;
7132
- const matches = (id) => matchCustomEquality(condition, id);
7133
- if (condition.operator === "is not") return matcher.every(matches);
7134
- return matcher.some(matches);
7135
- };
7136
- const assertCustom = (condition, order) => {
7137
- const { type } = condition;
7138
- switch (type) {
7139
- case "Fulfillment":
7140
- case "Fulfillments": return matchCustom(condition, order.fulfillment_id);
7141
- case "Store":
7142
- case "Stores": return matchCustom(condition, order.store_id);
7143
- case "Channel":
7144
- case "Channels": return matchCustom(condition, order.channel_id);
7145
- case "Brand":
7146
- case "Brands": return matchCustom(condition, order.brand_id);
7147
- default: throw new Error(`Condition ${type} not supported in custom matcher`);
7148
- }
7149
- };
7150
- //#endregion
7151
- //#region src/rules/enumMatcher.ts
7152
- const matchEnumOperator = (condition, matcher) => {
7153
- const { primitive, operator, value } = condition;
7154
- assert(primitive === "enum", primitiveError("enum"));
7155
- if (typeof matcher === "undefined") return false;
7156
- switch (operator) {
7157
- case "is":
7158
- assert(typeof value === "string", "Expected a single enum value");
7159
- return matcher === value;
7160
- case "is not":
7161
- assert(typeof value === "string", "Expected a single enum value");
7162
- return matcher !== value;
7163
- case "is any of":
7164
- assert(Array.isArray(value), "Expected a list of enum values");
7165
- return value.includes(matcher);
7166
- case "is none of":
7167
- assert(Array.isArray(value), "Expected a list of enum values");
7168
- return !value.includes(matcher);
7169
- default: throw new Error(`Operator ${operator} not supported in enum matcher`);
7170
- }
7171
- };
7172
- const NEGATIVE_ENUM_OPERATORS = ["is not", "is none of"];
7173
- /**
7174
- * Enum condition over a set (one value per tender): positive operators
7175
- * match when some value matches, negative ones when none matches.
7176
- */
7177
- const matchEnumSet = (condition, matcher) => {
7178
- if (typeof matcher === "undefined" || typeof matcher === "string") return matchEnumOperator(condition, matcher);
7179
- if (matcher.length === 0) return false;
7180
- const matches = (value) => matchEnumOperator(condition, value);
7181
- if (NEGATIVE_ENUM_OPERATORS.includes(condition.operator)) return matcher.every(matches);
7182
- return matcher.some(matches);
7183
- };
7184
- const paymentCollectAt = (condition, collectAt) => {
7185
- const matcherType = "Payment collect at";
7186
- assert(condition.primitive === "enum", primitiveError("enum"));
7187
- assert(condition.type === matcherType, typeError(matcherType));
7188
- return matchEnumSet(condition, collectAt);
7189
- };
7190
- const paymentMethod = (condition, method) => {
7191
- const matcherType = "Payment method";
7192
- assert(condition.primitive === "enum", primitiveError("enum"));
7193
- assert(condition.type === matcherType, typeError(matcherType));
7194
- return matchEnumSet(condition, method);
7195
- };
7196
- const paymentStatus = (condition, status) => {
7197
- const matcherType = "Payment status";
7198
- assert(condition.primitive === "enum", primitiveError("enum"));
7199
- assert(condition.type === matcherType, typeError(matcherType));
7200
- return matchEnumOperator(condition, status);
7201
- };
7202
- const CONDITION_INTERPRETATION_DEFAULT = "server";
7203
- const isInterpretationCompatible = (interpretation, runtime, fallback) => {
7204
- if (!runtime) return true;
7205
- const value = interpretation ?? fallback;
7206
- if (value === "both") return true;
7207
- return value === runtime;
7208
- };
7209
- //#endregion
7210
- //#region src/rules/numberMatcher.ts
7211
- const matchNumberOperator = (condition, matcher) => {
7212
- const { primitive, operator, value } = condition;
7213
- assert(primitive === "number", primitiveError("number"));
7214
- if (typeof matcher === "undefined") return false;
7215
- assert(typeof value === "number", "Invalid number value");
7216
- switch (operator) {
7217
- case "equal to": return matcher === value;
7218
- case "not equal to": return matcher !== value;
7219
- case "greater than": return matcher > value;
7220
- case "greater than or equal to": return matcher >= value;
7221
- case "less than": return matcher < value;
7222
- case "less than or equal to": return matcher <= value;
7223
- default: throw new Error(`Operator ${operator} not supported in number matcher`);
7224
- }
7225
- };
7226
- const orderIncludesProduct = (condition, quantity) => {
7227
- const { type, primitive, value } = condition;
7228
- const matcherType = "Order includes product";
7229
- assert(primitive === "number", primitiveError("number"));
7230
- assert(type === matcherType, typeError(matcherType));
7231
- assert(value != null, "Invalid value");
7232
- return matchNumberOperator(condition, quantity ?? 0);
7233
- };
7234
- const cartProductCount = (condition, count) => {
7235
- const { type, primitive, value } = condition;
7236
- const matcherType = "Cart product count";
7237
- assert(primitive === "number", primitiveError("number"));
7238
- assert(type === matcherType, typeError(matcherType));
7239
- assert(value != null, "Invalid value");
7240
- return matchNumberOperator(condition, count);
7241
- };
7242
- const orderIncludesProductWithTag$1 = (condition, matcher) => {
7243
- const { type, primitive, value } = condition;
7244
- const matcherType = "Order includes product with tag";
7245
- assert(primitive === "number", primitiveError("number"));
7246
- assert(type === matcherType, typeError(matcherType));
7247
- assert(value != null, "Invalid value");
7248
- return matchNumberOperator(condition, matcher);
7249
- };
7250
- //#endregion
7251
- //#region src/rules/stringCondition.utils.ts
7252
- const getCaseInsensitive = (condition) => {
7253
- return condition.caseInsensitive ?? false;
7254
- };
7255
- const normalizeStringForMatch = (value, caseInsensitive) => {
7256
- if (caseInsensitive) return value.toLowerCase();
7257
- return value;
7258
- };
7259
- const normalizePairForMatch = (matcher, expected, caseInsensitive) => {
7260
- return {
7261
- matcher: normalizeStringForMatch(matcher, caseInsensitive),
7262
- expected: normalizeStringForMatch(expected, caseInsensitive)
7263
- };
7264
- };
7265
- //#endregion
7266
- //#region src/rules/stringMatcher.ts
7267
- const matchStringOperator = (condition, matcher) => {
7268
- const { primitive, operator, value } = condition;
7269
- assert(primitive === "string", primitiveError("string"));
7270
- if (typeof matcher === "undefined") return false;
7271
- const caseInsensitive = getCaseInsensitive(condition);
7272
- const pair = normalizePairForMatch(matcher, String(value), caseInsensitive);
7273
- const normalizedMatcher = pair.matcher;
7274
- const normalizedExpected = pair.expected;
7275
- switch (operator) {
7276
- case "equal to": return normalizedMatcher === normalizedExpected;
7277
- case "not equal to": return normalizedMatcher !== normalizedExpected;
7278
- case "starts with": return normalizedMatcher.startsWith(normalizedExpected);
7279
- case "ends with": return normalizedMatcher.endsWith(normalizedExpected);
7280
- case "contains": return normalizedMatcher.includes(normalizedExpected);
7281
- default: throw new Error(`Operator ${operator} not supported in string matcher`);
7282
- }
7283
- };
7284
- const orderIncludesProductWithTag = (condition, matcher) => {
7285
- const { type, primitive, value } = condition;
7286
- const matcherType = "Order includes product with tag";
7287
- assert(primitive === "string", primitiveError("string"));
7288
- assert(type === matcherType, typeError(matcherType));
7289
- assert(value != null, "Invalid value");
7290
- return matchStringOperator(condition, matcher);
7291
- };
7292
- //#endregion
7293
- //#region src/rules/monitoringMatcher.ts
7294
- const windowOf = (condition) => {
7295
- if (!("window_minutes" in condition)) return void 0;
7296
- return condition.window_minutes;
7297
- };
7298
- const accumulatedEvents = (condition, count) => {
7299
- const { type, primitive, value } = condition;
7300
- const matcherType = "Accumulated events";
7301
- assert(primitive === "number", primitiveError("number"));
7302
- assert(type === matcherType, typeError(matcherType));
7303
- assert(value != null, "Invalid value");
7304
- if (typeof count === "undefined") return false;
7305
- return matchNumberOperator(condition, count);
7306
- };
7307
- const apiErrorRate = (condition, rate) => {
7308
- const { type, primitive, value } = condition;
7309
- const matcherType = "API error rate";
7310
- assert(primitive === "number", primitiveError("number"));
7311
- assert(type === matcherType, typeError(matcherType));
7312
- assert(value != null, "Invalid value");
7313
- if (typeof rate === "undefined") return false;
7314
- return matchNumberOperator(condition, rate);
7315
- };
7316
- const httpStatus = (condition, status) => {
7317
- const { type, primitive, value } = condition;
7318
- const matcherType = "HTTP status";
7319
- assert(primitive === "number", primitiveError("number"));
7320
- assert(type === matcherType, typeError(matcherType));
7321
- assert(value != null, "Invalid value");
7322
- if (typeof status === "undefined") return false;
7323
- return matchNumberOperator(condition, status);
7324
- };
7325
- const requestPath = (condition, path) => {
7326
- const { type, primitive, value } = condition;
7327
- const matcherType = "Request path";
7328
- assert(primitive === "string", primitiveError("string"));
7329
- assert(type === matcherType, typeError(matcherType));
7330
- assert(value != null, "Invalid value");
7331
- return matchStringOperator(condition, path);
7332
- };
7333
- const failureMessage = (condition, message) => {
7334
- const { type, primitive, value } = condition;
7335
- const matcherType = "Failure message";
7336
- assert(primitive === "string", primitiveError("string"));
7337
- assert(type === matcherType, typeError(matcherType));
7338
- assert(value != null, "Invalid value");
7339
- return matchStringOperator(condition, message);
7340
- };
7341
- const windowMinutes = (condition) => {
7342
- return windowOf(condition);
7343
- };
7344
- //#endregion
7345
- //#region src/rules/recurringDateCron.ts
7346
- var import_dayjs_min = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
6975
+ //#region ../../node_modules/.pnpm/dayjs@1.11.9/node_modules/dayjs/dayjs.min.js
6976
+ var require_dayjs_min = /* @__PURE__ */ __commonJSMin(((exports, module) => {
7347
6977
  (function(t, e) {
7348
6978
  "object" == typeof exports && "undefined" != typeof module ? module.exports = e() : "function" == typeof define && define.amd ? define(e) : (t = "undefined" != typeof globalThis ? globalThis : t || self).dayjs = e();
7349
6979
  })(exports, (function() {
@@ -7638,7 +7268,412 @@ var import_dayjs_min = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((
7638
7268
  return w(1e3 * t);
7639
7269
  }, w.en = D[g], w.Ls = D, w.p = {}, w;
7640
7270
  }));
7641
- })))(), 1);
7271
+ }));
7272
+ //#endregion
7273
+ //#region src/rules/assert.ts
7274
+ const assert = (value, message) => {
7275
+ if (value) return;
7276
+ throw new Error(message);
7277
+ };
7278
+ //#endregion
7279
+ //#region src/rules/common.ts
7280
+ const primitiveError = (primitive) => {
7281
+ return `Condition primitive must be ${primitive}`;
7282
+ };
7283
+ const typeError = (type) => {
7284
+ return `Condition type must be ${type}`;
7285
+ };
7286
+ const defaultConditionError = "Condition was not met";
7287
+ const getLineByProductId = (lines, productId) => {
7288
+ if (!productId) return void 0;
7289
+ return lines?.find((line) => line.id === productId);
7290
+ };
7291
+ const countCartProducts = (lines, includeModifiers) => {
7292
+ if (!lines?.length) return 0;
7293
+ return lines.filter((line) => {
7294
+ if (line.type === "FEE") return false;
7295
+ if (includeModifiers) return true;
7296
+ if (!(line.parent_uid == null)) return false;
7297
+ return line.type === "PRODUCT" || line.type == null;
7298
+ }).reduce((sum, line) => sum + line.quantity, 0);
7299
+ };
7300
+ const getLineTagValues = (lines, tag) => {
7301
+ if (!tag) return [];
7302
+ const values = [];
7303
+ for (const line of lines ?? []) {
7304
+ const value = line.tags?.[tag];
7305
+ if (typeof value === "undefined") continue;
7306
+ values.push(value);
7307
+ }
7308
+ return values;
7309
+ };
7310
+ //#endregion
7311
+ //#region src/rules/absoluteDateMatcher.ts
7312
+ var import_dayjs_min = /* @__PURE__ */ __toESM(require_dayjs_min(), 1);
7313
+ const matchAbsoluteDateOperator = (condition, matcher) => {
7314
+ const { primitive, operator, value } = condition;
7315
+ assert(primitive === "absolute date", primitiveError("absolute date"));
7316
+ if (typeof matcher === "undefined") return false;
7317
+ const matcherDate = (0, import_dayjs_min.default)(matcher);
7318
+ const expectedDate = (0, import_dayjs_min.default)(value);
7319
+ switch (operator) {
7320
+ case "is": return matcherDate.isSame(expectedDate, "day");
7321
+ case "is not": return !matcherDate.isSame(expectedDate, "day");
7322
+ case "is before": return matcherDate.isBefore(expectedDate, "day");
7323
+ case "is after": return matcherDate.isAfter(expectedDate, "day");
7324
+ default: {
7325
+ const msg = `Operator ${operator} not supported in absolute date matcher`;
7326
+ throw new Error(msg);
7327
+ }
7328
+ }
7329
+ };
7330
+ //#endregion
7331
+ //#region src/rules/booleanMatcher.ts
7332
+ const matchBooleanOperator = (condition, matcher) => {
7333
+ const { primitive, operator, value } = condition;
7334
+ assert(primitive === "boolean", primitiveError("boolean"));
7335
+ if (typeof matcher === "undefined") return false;
7336
+ assert(typeof value === "boolean", "Invalid boolean value");
7337
+ switch (operator) {
7338
+ case "is": return matcher === value;
7339
+ case "is not": return matcher !== value;
7340
+ default: throw new Error(`Operator ${operator} not supported in boolean matcher`);
7341
+ }
7342
+ };
7343
+ const isAnonymousCustomer = (condition, isAnonymous) => {
7344
+ const { type, primitive, value } = condition;
7345
+ const matcherType = "Customer is anonymous";
7346
+ assert(primitive === "boolean", primitiveError("boolean"));
7347
+ assert(type === matcherType, typeError(matcherType));
7348
+ assert(value != null, "Invalid value");
7349
+ return matchBooleanOperator(condition, isAnonymous ?? false);
7350
+ };
7351
+ const orderIncludesProductWithTag$3 = (condition, matcher) => {
7352
+ const { type, primitive, value } = condition;
7353
+ const matcherType = "Order includes product with tag";
7354
+ assert(primitive === "boolean", primitiveError("boolean"));
7355
+ assert(type === matcherType, typeError(matcherType));
7356
+ assert(value != null, "Invalid value");
7357
+ return matchBooleanOperator(condition, matcher);
7358
+ };
7359
+ const isAnonymousOrder = (condition, isAnonymous) => {
7360
+ const { type, primitive, value } = condition;
7361
+ const matcherType = "Is anonymous order";
7362
+ assert(primitive === "boolean", primitiveError("boolean"));
7363
+ assert(type === matcherType, typeError(matcherType));
7364
+ assert(value != null, "Invalid value");
7365
+ return matchBooleanOperator(condition, isAnonymous ?? false);
7366
+ };
7367
+ const paymentIsMixed = (condition, isMixed) => {
7368
+ const { type, primitive, value } = condition;
7369
+ const matcherType = "Payment is mixed";
7370
+ assert(primitive === "boolean", primitiveError("boolean"));
7371
+ assert(type === matcherType, typeError(matcherType));
7372
+ assert(value != null, "Invalid value");
7373
+ return matchBooleanOperator(condition, isMixed ?? false);
7374
+ };
7375
+ const paymentCaptured = (condition, captured) => {
7376
+ const { type, primitive, value } = condition;
7377
+ const matcherType = "Payment captured";
7378
+ assert(primitive === "boolean", primitiveError("boolean"));
7379
+ assert(type === matcherType, typeError(matcherType));
7380
+ assert(value != null, "Invalid value");
7381
+ return matchBooleanOperator(condition, captured ?? false);
7382
+ };
7383
+ //#endregion
7384
+ //#region src/rules/currencyMatcher.ts
7385
+ const matchCurrencyOperator = (condition, matcher) => {
7386
+ const { primitive, operator, value } = condition;
7387
+ assert(primitive === "currency", primitiveError("currency"));
7388
+ if (typeof matcher === "undefined") return false;
7389
+ assert(typeof value === "number", "Invalid currency value");
7390
+ switch (operator) {
7391
+ case "equal to": return matcher === value;
7392
+ case "not equal to": return matcher !== value;
7393
+ case "greater than": return matcher > value;
7394
+ case "greater than or equal to": return matcher >= value;
7395
+ case "less than": return matcher < value;
7396
+ case "less than or equal to": return matcher <= value;
7397
+ default: throw new Error(`Operator ${operator} not supported in currency matcher`);
7398
+ }
7399
+ };
7400
+ const orderIncludesProductWithTag$2 = (condition, matcher) => {
7401
+ const { type, primitive, value } = condition;
7402
+ const matcherType = "Order includes product with tag";
7403
+ assert(primitive === "currency", primitiveError("currency"));
7404
+ assert(type === matcherType, typeError(matcherType));
7405
+ assert(value != null, "Invalid value");
7406
+ return matchCurrencyOperator(condition, matcher);
7407
+ };
7408
+ const orderTotal = (condition, total) => {
7409
+ const { type, primitive, value } = condition;
7410
+ const matcherType = "Order total";
7411
+ assert(primitive === "currency", primitiveError("currency"));
7412
+ assert(type === matcherType, typeError(matcherType));
7413
+ assert(value != null, "Invalid value");
7414
+ return matchCurrencyOperator(condition, total);
7415
+ };
7416
+ //#endregion
7417
+ //#region src/rules/customMatcher.ts
7418
+ const MULTI_OPERATORS = [
7419
+ "is any of",
7420
+ "is none of",
7421
+ "is all of"
7422
+ ];
7423
+ const isMultiOperator = (operator) => {
7424
+ return MULTI_OPERATORS.includes(operator);
7425
+ };
7426
+ const splitCsv = (value) => {
7427
+ return value.split(",").map((part) => part.trim()).filter(Boolean);
7428
+ };
7429
+ const matchCustomEquality = (condition, matcher) => {
7430
+ const { primitive, operator, value } = condition;
7431
+ assert(primitive === "custom", primitiveError("custom"));
7432
+ if (typeof matcher === "undefined") return false;
7433
+ assert(typeof value === "string", "Invalid custom value");
7434
+ switch (operator) {
7435
+ case "is": return matcher === value;
7436
+ case "is not": return matcher !== value;
7437
+ default: throw new Error(`Operator ${operator} not supported in custom matcher`);
7438
+ }
7439
+ };
7440
+ const matchCustomMulti = (condition, matcher) => {
7441
+ const { primitive, operator, value } = condition;
7442
+ assert(primitive === "custom", primitiveError("custom"));
7443
+ if (typeof matcher === "undefined") return false;
7444
+ assert(typeof value === "string", "Invalid custom value");
7445
+ const allowed = splitCsv(value);
7446
+ if (allowed.length === 0) return false;
7447
+ const candidates = typeof matcher === "object" ? matcher : [matcher];
7448
+ if (candidates.length === 0) return false;
7449
+ if (operator === "is any of") return allowed.some((id) => candidates.includes(id));
7450
+ if (operator === "is none of") return !allowed.some((id) => candidates.includes(id));
7451
+ if (operator === "is all of") return allowed.every((id) => candidates.includes(id));
7452
+ throw new Error(`Operator ${operator} not supported in custom matcher`);
7453
+ };
7454
+ /**
7455
+ * The operator picks the arity: `Payment` is single-select in order rules and
7456
+ * multi-select in fulfillment workflows. `matcher` may be a set (one id per
7457
+ * tender): `is` matches when some id matches, `is not` when none does.
7458
+ */
7459
+ const matchCustom = (condition, matcher) => {
7460
+ if (isMultiOperator(condition.operator)) return matchCustomMulti(condition, matcher);
7461
+ if (typeof matcher === "undefined" || typeof matcher === "string") return matchCustomEquality(condition, matcher);
7462
+ if (matcher.length === 0) return false;
7463
+ const matches = (id) => matchCustomEquality(condition, id);
7464
+ if (condition.operator === "is not") return matcher.every(matches);
7465
+ return matcher.some(matches);
7466
+ };
7467
+ const assertCustom = (condition, order) => {
7468
+ const { type } = condition;
7469
+ switch (type) {
7470
+ case "Fulfillment":
7471
+ case "Fulfillments": return matchCustom(condition, order.fulfillment_id);
7472
+ case "Store":
7473
+ case "Stores": return matchCustom(condition, order.store_id);
7474
+ case "Channel":
7475
+ case "Channels": return matchCustom(condition, order.channel_id);
7476
+ case "Brand":
7477
+ case "Brands": return matchCustom(condition, order.brand_id);
7478
+ default: throw new Error(`Condition ${type} not supported in custom matcher`);
7479
+ }
7480
+ };
7481
+ //#endregion
7482
+ //#region src/rules/enumMatcher.ts
7483
+ const matchEnumOperator = (condition, matcher) => {
7484
+ const { primitive, operator, value } = condition;
7485
+ assert(primitive === "enum", primitiveError("enum"));
7486
+ if (typeof matcher === "undefined") return false;
7487
+ switch (operator) {
7488
+ case "is":
7489
+ assert(typeof value === "string", "Expected a single enum value");
7490
+ return matcher === value;
7491
+ case "is not":
7492
+ assert(typeof value === "string", "Expected a single enum value");
7493
+ return matcher !== value;
7494
+ case "is any of":
7495
+ assert(Array.isArray(value), "Expected a list of enum values");
7496
+ return value.includes(matcher);
7497
+ case "is none of":
7498
+ assert(Array.isArray(value), "Expected a list of enum values");
7499
+ return !value.includes(matcher);
7500
+ default: throw new Error(`Operator ${operator} not supported in enum matcher`);
7501
+ }
7502
+ };
7503
+ const NEGATIVE_ENUM_OPERATORS = ["is not", "is none of"];
7504
+ /**
7505
+ * Enum condition over a set (one value per tender): positive operators
7506
+ * match when some value matches, negative ones when none matches.
7507
+ */
7508
+ const matchEnumSet = (condition, matcher) => {
7509
+ if (typeof matcher === "undefined" || typeof matcher === "string") return matchEnumOperator(condition, matcher);
7510
+ if (matcher.length === 0) return false;
7511
+ const matches = (value) => matchEnumOperator(condition, value);
7512
+ if (NEGATIVE_ENUM_OPERATORS.includes(condition.operator)) return matcher.every(matches);
7513
+ return matcher.some(matches);
7514
+ };
7515
+ const paymentCollectAt = (condition, collectAt) => {
7516
+ const matcherType = "Payment collect at";
7517
+ assert(condition.primitive === "enum", primitiveError("enum"));
7518
+ assert(condition.type === matcherType, typeError(matcherType));
7519
+ return matchEnumSet(condition, collectAt);
7520
+ };
7521
+ const paymentMethod = (condition, method) => {
7522
+ const matcherType = "Payment method";
7523
+ assert(condition.primitive === "enum", primitiveError("enum"));
7524
+ assert(condition.type === matcherType, typeError(matcherType));
7525
+ return matchEnumSet(condition, method);
7526
+ };
7527
+ const paymentStatus = (condition, status) => {
7528
+ const matcherType = "Payment status";
7529
+ assert(condition.primitive === "enum", primitiveError("enum"));
7530
+ assert(condition.type === matcherType, typeError(matcherType));
7531
+ return matchEnumOperator(condition, status);
7532
+ };
7533
+ const CONDITION_INTERPRETATION_DEFAULT = "server";
7534
+ const isInterpretationCompatible = (interpretation, runtime, fallback) => {
7535
+ if (!runtime) return true;
7536
+ const value = interpretation ?? fallback;
7537
+ if (value === "both") return true;
7538
+ return value === runtime;
7539
+ };
7540
+ //#endregion
7541
+ //#region src/rules/numberMatcher.ts
7542
+ const matchNumberOperator = (condition, matcher) => {
7543
+ const { primitive, operator, value } = condition;
7544
+ assert(primitive === "number", primitiveError("number"));
7545
+ if (typeof matcher === "undefined") return false;
7546
+ assert(typeof value === "number", "Invalid number value");
7547
+ switch (operator) {
7548
+ case "equal to": return matcher === value;
7549
+ case "not equal to": return matcher !== value;
7550
+ case "greater than": return matcher > value;
7551
+ case "greater than or equal to": return matcher >= value;
7552
+ case "less than": return matcher < value;
7553
+ case "less than or equal to": return matcher <= value;
7554
+ default: throw new Error(`Operator ${operator} not supported in number matcher`);
7555
+ }
7556
+ };
7557
+ const orderIncludesProduct = (condition, quantity) => {
7558
+ const { type, primitive, value } = condition;
7559
+ const matcherType = "Order includes product";
7560
+ assert(primitive === "number", primitiveError("number"));
7561
+ assert(type === matcherType, typeError(matcherType));
7562
+ assert(value != null, "Invalid value");
7563
+ return matchNumberOperator(condition, quantity ?? 0);
7564
+ };
7565
+ const cartProductCount = (condition, count) => {
7566
+ const { type, primitive, value } = condition;
7567
+ const matcherType = "Cart product count";
7568
+ assert(primitive === "number", primitiveError("number"));
7569
+ assert(type === matcherType, typeError(matcherType));
7570
+ assert(value != null, "Invalid value");
7571
+ return matchNumberOperator(condition, count);
7572
+ };
7573
+ const orderIncludesProductWithTag$1 = (condition, matcher) => {
7574
+ const { type, primitive, value } = condition;
7575
+ const matcherType = "Order includes product with tag";
7576
+ assert(primitive === "number", primitiveError("number"));
7577
+ assert(type === matcherType, typeError(matcherType));
7578
+ assert(value != null, "Invalid value");
7579
+ return matchNumberOperator(condition, matcher);
7580
+ };
7581
+ //#endregion
7582
+ //#region src/rules/stringCondition.utils.ts
7583
+ const getCaseInsensitive = (condition) => {
7584
+ return condition.caseInsensitive ?? false;
7585
+ };
7586
+ const normalizeStringForMatch = (value, caseInsensitive) => {
7587
+ if (caseInsensitive) return value.toLowerCase();
7588
+ return value;
7589
+ };
7590
+ const normalizePairForMatch = (matcher, expected, caseInsensitive) => {
7591
+ return {
7592
+ matcher: normalizeStringForMatch(matcher, caseInsensitive),
7593
+ expected: normalizeStringForMatch(expected, caseInsensitive)
7594
+ };
7595
+ };
7596
+ //#endregion
7597
+ //#region src/rules/stringMatcher.ts
7598
+ const matchStringOperator = (condition, matcher) => {
7599
+ const { primitive, operator, value } = condition;
7600
+ assert(primitive === "string", primitiveError("string"));
7601
+ if (typeof matcher === "undefined") return false;
7602
+ const caseInsensitive = getCaseInsensitive(condition);
7603
+ const pair = normalizePairForMatch(matcher, String(value), caseInsensitive);
7604
+ const normalizedMatcher = pair.matcher;
7605
+ const normalizedExpected = pair.expected;
7606
+ switch (operator) {
7607
+ case "equal to": return normalizedMatcher === normalizedExpected;
7608
+ case "not equal to": return normalizedMatcher !== normalizedExpected;
7609
+ case "starts with": return normalizedMatcher.startsWith(normalizedExpected);
7610
+ case "ends with": return normalizedMatcher.endsWith(normalizedExpected);
7611
+ case "contains": return normalizedMatcher.includes(normalizedExpected);
7612
+ default: throw new Error(`Operator ${operator} not supported in string matcher`);
7613
+ }
7614
+ };
7615
+ const orderIncludesProductWithTag = (condition, matcher) => {
7616
+ const { type, primitive, value } = condition;
7617
+ const matcherType = "Order includes product with tag";
7618
+ assert(primitive === "string", primitiveError("string"));
7619
+ assert(type === matcherType, typeError(matcherType));
7620
+ assert(value != null, "Invalid value");
7621
+ return matchStringOperator(condition, matcher);
7622
+ };
7623
+ //#endregion
7624
+ //#region src/rules/monitoringMatcher.ts
7625
+ const windowOf = (condition) => {
7626
+ if (!("window_minutes" in condition)) return void 0;
7627
+ return condition.window_minutes;
7628
+ };
7629
+ const accumulatedEvents = (condition, count) => {
7630
+ const { type, primitive, value } = condition;
7631
+ const matcherType = "Accumulated events";
7632
+ assert(primitive === "number", primitiveError("number"));
7633
+ assert(type === matcherType, typeError(matcherType));
7634
+ assert(value != null, "Invalid value");
7635
+ if (typeof count === "undefined") return false;
7636
+ return matchNumberOperator(condition, count);
7637
+ };
7638
+ const apiErrorRate = (condition, rate) => {
7639
+ const { type, primitive, value } = condition;
7640
+ const matcherType = "API error rate";
7641
+ assert(primitive === "number", primitiveError("number"));
7642
+ assert(type === matcherType, typeError(matcherType));
7643
+ assert(value != null, "Invalid value");
7644
+ if (typeof rate === "undefined") return false;
7645
+ return matchNumberOperator(condition, rate);
7646
+ };
7647
+ const httpStatus = (condition, status) => {
7648
+ const { type, primitive, value } = condition;
7649
+ const matcherType = "HTTP status";
7650
+ assert(primitive === "number", primitiveError("number"));
7651
+ assert(type === matcherType, typeError(matcherType));
7652
+ assert(value != null, "Invalid value");
7653
+ if (typeof status === "undefined") return false;
7654
+ return matchNumberOperator(condition, status);
7655
+ };
7656
+ const requestPath = (condition, path) => {
7657
+ const { type, primitive, value } = condition;
7658
+ const matcherType = "Request path";
7659
+ assert(primitive === "string", primitiveError("string"));
7660
+ assert(type === matcherType, typeError(matcherType));
7661
+ assert(value != null, "Invalid value");
7662
+ return matchStringOperator(condition, path);
7663
+ };
7664
+ const failureMessage = (condition, message) => {
7665
+ const { type, primitive, value } = condition;
7666
+ const matcherType = "Failure message";
7667
+ assert(primitive === "string", primitiveError("string"));
7668
+ assert(type === matcherType, typeError(matcherType));
7669
+ assert(value != null, "Invalid value");
7670
+ return matchStringOperator(condition, message);
7671
+ };
7672
+ const windowMinutes = (condition) => {
7673
+ return windowOf(condition);
7674
+ };
7675
+ //#endregion
7676
+ //#region src/rules/recurringDateCron.ts
7642
7677
  import_dayjs_min.default.extend(utc);
7643
7678
  import_dayjs_min.default.extend(timezone);
7644
7679
  import_dayjs_min.default.extend(isBetween);
@@ -7835,6 +7870,47 @@ const currentDate = (condition, referenceDate) => {
7835
7870
  return matchRecurringDateOperator(condition, current);
7836
7871
  };
7837
7872
  //#endregion
7873
+ //#region src/rules/relativeDateMatcher.ts
7874
+ const matchRelativeDateOperator = (condition, matcher, referenceDate) => {
7875
+ const { primitive, operator, value } = condition;
7876
+ assert(primitive === "relative date", primitiveError("relative date"));
7877
+ if (typeof matcher === "undefined") return false;
7878
+ assert(typeof value === "string", "Invalid relative date value");
7879
+ const [amountString, unitRaw] = value.split("/");
7880
+ assert(amountString && unitRaw, "Amount & Unit required");
7881
+ const amount = parseInt(amountString, 10);
7882
+ const unit = unitRaw;
7883
+ const issueDate = (0, import_dayjs_min.default)(referenceDate ?? (/* @__PURE__ */ new Date()).toISOString());
7884
+ const matcherDate = (0, import_dayjs_min.default)(matcher);
7885
+ switch (operator) {
7886
+ case "occurred": {
7887
+ const endDate = issueDate.subtract(amount, unit).endOf(unit);
7888
+ const startDate = endDate.startOf(unit);
7889
+ const sameStart = matcherDate.isSame(startDate, unit);
7890
+ const afterStart = matcherDate.isAfter(startDate);
7891
+ const sameEnd = matcherDate.isSame(endDate, unit);
7892
+ const beforeEnd = matcherDate.isBefore(endDate);
7893
+ return (sameStart || afterStart) && (sameEnd || beforeEnd);
7894
+ }
7895
+ case "occurred in the last": {
7896
+ const targetDate = issueDate.subtract(amount, unit);
7897
+ const sameTarget = matcherDate.isSame(targetDate, unit);
7898
+ const afterTarget = matcherDate.isAfter(targetDate);
7899
+ const sameIssue = matcherDate.isSame(issueDate, unit);
7900
+ const beforeIssue = matcherDate.isBefore(issueDate);
7901
+ return (sameTarget || afterTarget) && (sameIssue || beforeIssue);
7902
+ }
7903
+ case "occurred more than": {
7904
+ const targetDate = issueDate.subtract(amount, unit);
7905
+ return matcherDate.isBefore(targetDate, unit);
7906
+ }
7907
+ default: {
7908
+ const msg = `Operator ${operator} not supported in relative date matcher`;
7909
+ throw new Error(msg);
7910
+ }
7911
+ }
7912
+ };
7913
+ //#endregion
7838
7914
  //#region src/rules/RulesEngine.ts
7839
7915
  var RulesEngine = class {
7840
7916
  order;
@@ -7932,6 +8008,7 @@ var RulesEngine = class {
7932
8008
  const monitoring = this.getDependency("monitoring", type);
7933
8009
  return httpStatus(condition, monitoring.http_status);
7934
8010
  }
8011
+ if (type === "Customer age") return matchNumberOperator(condition, this.customerAge());
7935
8012
  throw new Error(`Condition ${type} not supported in number matcher`);
7936
8013
  };
7937
8014
  assertBoolean = (condition) => {
@@ -7989,17 +8066,40 @@ var RulesEngine = class {
7989
8066
  default: throw new Error(`Condition ${type} not supported in enum matcher`);
7990
8067
  }
7991
8068
  };
8069
+ customerAge = () => {
8070
+ const dob = this.customer?.dob;
8071
+ if (!dob) return void 0;
8072
+ const born = new Date(dob);
8073
+ const now = new Date(this.referenceDate ?? Date.now());
8074
+ if (Number.isNaN(born.getTime()) || Number.isNaN(now.getTime())) return;
8075
+ let age = now.getUTCFullYear() - born.getUTCFullYear();
8076
+ const month = now.getUTCMonth() - born.getUTCMonth();
8077
+ const beforeBirthday = now.getUTCDate() < born.getUTCDate();
8078
+ if (month < 0 || month === 0 && beforeBirthday) age -= 1;
8079
+ return age;
8080
+ };
8081
+ customerDate = (type) => {
8082
+ const customer = this.customer;
8083
+ if (type === "Birthday") return customer?.dob;
8084
+ if (type === "Registration") return customer?.registered_at;
8085
+ if (type === "Customer last visit") return customer?.last_visit_at;
8086
+ if (type === "Customer last session") return customer?.last_session_at;
8087
+ };
7992
8088
  assertAbsoluteDate = (condition) => {
7993
8089
  const { primitive, type } = condition;
7994
8090
  assert(primitive === "absolute date", primitiveError("absolute date"));
7995
- const msg = `Condition ${type} not supported in absolute date matcher`;
7996
- throw new Error(msg);
8091
+ if (type !== "Today") {
8092
+ const msg = `Condition ${type} not supported in absolute date matcher`;
8093
+ throw new Error(msg);
8094
+ }
8095
+ const today = this.referenceDate ?? (/* @__PURE__ */ new Date()).toISOString();
8096
+ return matchAbsoluteDateOperator(condition, today);
7997
8097
  };
7998
8098
  assertRelativeDate = (condition) => {
7999
8099
  const { primitive, type } = condition;
8000
8100
  assert(primitive === "relative date", primitiveError("relative date"));
8001
- const msg = `Condition ${type} not supported in relative date matcher`;
8002
- throw new Error(msg);
8101
+ const fact = this.customerDate(type);
8102
+ return matchRelativeDateOperator(condition, fact, this.referenceDate);
8003
8103
  };
8004
8104
  assertRecurringDate = (condition) => {
8005
8105
  const { primitive, type } = condition;
@@ -8104,66 +8204,6 @@ var RulesEngine = class {
8104
8204
  };
8105
8205
  };
8106
8206
  //#endregion
8107
- //#region src/rules/absoluteDateMatcher.ts
8108
- const matchAbsoluteDateOperator = (condition, matcher) => {
8109
- const { primitive, operator, value } = condition;
8110
- assert(primitive === "absolute date", primitiveError("absolute date"));
8111
- if (typeof matcher === "undefined") return false;
8112
- const matcherDate = (0, import_dayjs_min.default)(matcher);
8113
- const expectedDate = (0, import_dayjs_min.default)(value);
8114
- switch (operator) {
8115
- case "is": return matcherDate.isSame(expectedDate, "day");
8116
- case "is not": return !matcherDate.isSame(expectedDate, "day");
8117
- case "is before": return matcherDate.isBefore(expectedDate, "day");
8118
- case "is after": return matcherDate.isAfter(expectedDate, "day");
8119
- default: {
8120
- const msg = `Operator ${operator} not supported in absolute date matcher`;
8121
- throw new Error(msg);
8122
- }
8123
- }
8124
- };
8125
- //#endregion
8126
- //#region src/rules/relativeDateMatcher.ts
8127
- const matchRelativeDateOperator = (condition, matcher, referenceDate) => {
8128
- const { primitive, operator, value } = condition;
8129
- assert(primitive === "relative date", primitiveError("relative date"));
8130
- if (typeof matcher === "undefined") return false;
8131
- assert(typeof value === "string", "Invalid relative date value");
8132
- const [amountString, unitRaw] = value.split("/");
8133
- assert(amountString && unitRaw, "Amount & Unit required");
8134
- const amount = parseInt(amountString, 10);
8135
- const unit = unitRaw;
8136
- const issueDate = (0, import_dayjs_min.default)(referenceDate ?? (/* @__PURE__ */ new Date()).toISOString());
8137
- const matcherDate = (0, import_dayjs_min.default)(matcher);
8138
- switch (operator) {
8139
- case "occurred": {
8140
- const endDate = issueDate.subtract(amount, unit).endOf(unit);
8141
- const startDate = endDate.startOf(unit);
8142
- const sameStart = matcherDate.isSame(startDate, unit);
8143
- const afterStart = matcherDate.isAfter(startDate);
8144
- const sameEnd = matcherDate.isSame(endDate, unit);
8145
- const beforeEnd = matcherDate.isBefore(endDate);
8146
- return (sameStart || afterStart) && (sameEnd || beforeEnd);
8147
- }
8148
- case "occurred in the last": {
8149
- const targetDate = issueDate.subtract(amount, unit);
8150
- const sameTarget = matcherDate.isSame(targetDate, unit);
8151
- const afterTarget = matcherDate.isAfter(targetDate);
8152
- const sameIssue = matcherDate.isSame(issueDate, unit);
8153
- const beforeIssue = matcherDate.isBefore(issueDate);
8154
- return (sameTarget || afterTarget) && (sameIssue || beforeIssue);
8155
- }
8156
- case "occurred more than": {
8157
- const targetDate = issueDate.subtract(amount, unit);
8158
- return matcherDate.isBefore(targetDate, unit);
8159
- }
8160
- default: {
8161
- const msg = `Operator ${operator} not supported in relative date matcher`;
8162
- throw new Error(msg);
8163
- }
8164
- }
8165
- };
8166
- //#endregion
8167
8207
  //#region src/rules/checkout.ts
8168
8208
  const DEFAULT_SHIPPING_FIELDS = {
8169
8209
  address_line1: "required",
@@ -8616,4 +8656,4 @@ const cardPreviewGroups = (value) => {
8616
8656
  return groups;
8617
8657
  };
8618
8658
  //#endregion
8619
- export { CARD_BRANDS, DEFAULT_RECOMMENDATION_TYPES, Order, OrderSyncPendingError, OrderValidationError, RecurringDateCron, RulesEngine, StorefrontHttpError, addLines, auth, buildCustomerSnapshot, cardBrandSpec, cardDigits, cardNumberGaps, cardNumberMaxLength, cardPreviewGroups, caretAfterDigits, collectOrderLineDiffs, computeStartingPrice, countCartProducts, coveredAmount, createSession, currentNodes, cvcMaxLength, defaultConditionError, detectCardBrand, flattenMenuCategories, flushLogs, formatCardNumber, formatCvc, formatExpiry, fromOrderLines, genId, genOrderId, getLogContext, getScheduleWindow, indexMenu, isCardNumberComplete, isCardNumberDefinitelyInvalid, isCheckoutFieldFilled, isCvcComplete, isDeviceCharged, isDeviceNotLinked, isExpiryComplete, isKioskUnauthorized, isMilestoneStepDone, isOnSchedule, isOrderValidationError, isPosDeviceNotLinked, isPosUnauthorized, isRealPaymentMethod, isStorefrontNotFound, isVariantBase, listMenusStream, localizePosError, log, logError, luhnCheck, matchAbsoluteDateOperator, matchBooleanOperator, matchCurrencyOperator, matchEnumOperator, matchNumberOperator, matchRecurringDateOperator, matchRelativeDateOperator, matchStringOperator, milestoneSteps, milestoneTrack, nestMenuCategories, normalizeCardholderName, 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 };