@fire-spark/core 0.11.1 → 0.12.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 (2) hide show
  1. package/dist/index.js +729 -39
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -1649,11 +1649,11 @@ const randomUUID = () => {
1649
1649
  };
1650
1650
  //#endregion
1651
1651
  //#region src/productCustomization/toOrderLines.ts
1652
- const displayName = (text, locale) => {
1652
+ const displayName$1 = (text, locale) => {
1653
1653
  if (text[locale]) return text[locale];
1654
1654
  return Object.values(text)[0] ?? "";
1655
1655
  };
1656
- const applyUnitMoney = (config) => {
1656
+ const applyUnitMoney$1 = (config) => {
1657
1657
  const qty = new Big(config.quantity);
1658
1658
  const unitDiscount = config.unitSubtotal.times(config.discountRate);
1659
1659
  const net = config.unitSubtotal.minus(unitDiscount);
@@ -1732,7 +1732,7 @@ const buildLine = (args) => {
1732
1732
  ...b2,
1733
1733
  ...b3
1734
1734
  };
1735
- const money = applyUnitMoney(moneyArgs);
1735
+ const money = applyUnitMoney$1(moneyArgs);
1736
1736
  const imageUrl = args.product.image_url ?? void 0;
1737
1737
  const imagePatch = imageUrl ? { image_url: imageUrl } : {};
1738
1738
  const catalog = lineCatalogFields(args.product);
@@ -1820,7 +1820,7 @@ const appendModifierLine = (modifier, groupId, parent, locale) => {
1820
1820
  const unitSubtotal = new Big(modifier.linePrice).div(modifier.quantity);
1821
1821
  const l1 = {
1822
1822
  product,
1823
- name: displayName(product.name, locale),
1823
+ name: displayName$1(product.name, locale),
1824
1824
  quantity: modifier.quantity,
1825
1825
  unitSubtotal
1826
1826
  };
@@ -1844,7 +1844,7 @@ const appendModifierLine = (modifier, groupId, parent, locale) => {
1844
1844
  const toOrderLines = (root, locale) => {
1845
1845
  if (root.quantity <= 0) return [];
1846
1846
  if (isVariantBase(root.data)) return [];
1847
- const name = displayName(root.data.name, locale);
1847
+ const name = displayName$1(root.data.name, locale);
1848
1848
  const unitSubtotal = new Big(root.data.pricing.price);
1849
1849
  const t1 = {
1850
1850
  product: root.data,
@@ -6603,6 +6603,608 @@ const milestoneSteps = (workflow, currentNodeIds) => {
6603
6603
  };
6604
6604
  };
6605
6605
  //#endregion
6606
+ //#region src/order/placeCartLines.ts
6607
+ const selectionKey = (line, kidsOf, cache) => {
6608
+ const cached = cache.get(line.uid);
6609
+ if (cached) return cached;
6610
+ const nested = (kidsOf.get(line.uid) ?? []).map((child) => {
6611
+ return selectionKey(child, kidsOf, cache);
6612
+ });
6613
+ nested.sort((left, right) => left.localeCompare(right));
6614
+ const key = `${line.id}x${line.quantity}(${nested.join(",")})`;
6615
+ cache.set(line.uid, key);
6616
+ return key;
6617
+ };
6618
+ const parentGroups = (lookup, product) => {
6619
+ return product.pricing.modifier_group_ids.flatMap((groupId) => {
6620
+ const group = lookup.modifierGroupsById[groupId];
6621
+ return group ? [group] : [];
6622
+ });
6623
+ };
6624
+ const buildSlots = (lookup, groups) => {
6625
+ const slots = [];
6626
+ for (const group of groups) {
6627
+ const seen = /* @__PURE__ */ new Set();
6628
+ for (const modifierId of group.modifier_ids) {
6629
+ if (seen.has(modifierId)) continue;
6630
+ seen.add(modifierId);
6631
+ const product = lookup.productsById[modifierId];
6632
+ const entry = product ? resolvePricingModifier(product, group.id) : null;
6633
+ const modifierMax = entry?.maximum_quantity ?? group.maximum_quantity;
6634
+ const row = {
6635
+ group,
6636
+ modifierId,
6637
+ modifierMin: entry?.minimum_quantity ?? 0
6638
+ };
6639
+ const counts = {
6640
+ modifierMax,
6641
+ instances: 0,
6642
+ next: 2
6643
+ };
6644
+ slots.push({
6645
+ ...row,
6646
+ ...counts
6647
+ });
6648
+ }
6649
+ }
6650
+ return slots;
6651
+ };
6652
+ const listingsFor = (lookup, groups, childId) => {
6653
+ const direct = [];
6654
+ for (const group of groups) {
6655
+ if (!group.modifier_ids.includes(childId)) continue;
6656
+ direct.push({
6657
+ group,
6658
+ modifierId: childId
6659
+ });
6660
+ }
6661
+ if (direct.length > 0) return direct;
6662
+ const child = lookup.productsById[childId];
6663
+ const viaBase = [];
6664
+ for (const entry of child?.pricing.variants ?? []) {
6665
+ const base = lookup.productsById[entry.product_id];
6666
+ if (!child || !base) continue;
6667
+ if (!selectionForVariant(lookup, base, child)) continue;
6668
+ for (const group of groups) {
6669
+ if (!group.modifier_ids.includes(base.id)) continue;
6670
+ viaBase.push({
6671
+ group,
6672
+ modifierId: base.id
6673
+ });
6674
+ }
6675
+ }
6676
+ return viaBase;
6677
+ };
6678
+ const slotKey = (groupId, modifierId) => {
6679
+ return `${groupId}/${modifierId}`;
6680
+ };
6681
+ /** Same product id more than once under one parent, at any depth. */
6682
+ const hasRepeatedProductSibling = (lines, rootUid) => {
6683
+ const kidsOf = /* @__PURE__ */ new Map();
6684
+ for (const line of lines) {
6685
+ if (line.parent_uid == null) continue;
6686
+ const list = kidsOf.get(line.parent_uid) ?? [];
6687
+ list.push(line);
6688
+ kidsOf.set(line.parent_uid, list);
6689
+ }
6690
+ const walk = (uid) => {
6691
+ const seen = /* @__PURE__ */ new Set();
6692
+ for (const child of kidsOf.get(uid) ?? []) {
6693
+ if (seen.has(child.id)) return true;
6694
+ seen.add(child.id);
6695
+ if (walk(child.uid)) return true;
6696
+ }
6697
+ return false;
6698
+ };
6699
+ return walk(rootUid);
6700
+ };
6701
+ const noteRules = (state, broken) => {
6702
+ if (broken) state.rulesBroken = true;
6703
+ };
6704
+ /** Search steps per parent. Far above any real menu. A cart that needs more
6705
+ * is rejected: a partial search never accepts anything. */
6706
+ const SEARCH_BUDGET = 4096;
6707
+ const OWN_MONEY = [
6708
+ "unit_subtotal",
6709
+ "subtotal",
6710
+ "unit_discount",
6711
+ "discount_total",
6712
+ "unit_tax_total",
6713
+ "tax_total",
6714
+ "unit_total",
6715
+ "total",
6716
+ "discount_rate"
6717
+ ];
6718
+ const emptyUsage = () => {
6719
+ return {
6720
+ bySlot: /* @__PURE__ */ new Map(),
6721
+ byGroup: /* @__PURE__ */ new Map()
6722
+ };
6723
+ };
6724
+ const addUsage = (usage, slot, quantity) => {
6725
+ const own = usage.bySlot.get(slot) ?? 0;
6726
+ usage.bySlot.set(slot, own + quantity);
6727
+ const group = usage.byGroup.get(slot.group.id) ?? 0;
6728
+ usage.byGroup.set(slot.group.id, group + quantity);
6729
+ };
6730
+ const hasRoom = (usage, slot, quantity) => {
6731
+ const own = usage.bySlot.get(slot) ?? 0;
6732
+ if ((usage.byGroup.get(slot.group.id) ?? 0) + quantity > slot.group.maximum_quantity) return false;
6733
+ return own + quantity <= slot.modifierMax;
6734
+ };
6735
+ /** Every instance counts: usage is the sum over all sibling lines. A slot's
6736
+ * own minimum binds once the slot is used, as in the customization session.
6737
+ * An unused slot owes nothing; the group minimum is what requires a pick. */
6738
+ const limitsBroken = (groups, slots, usage) => {
6739
+ for (const group of groups) {
6740
+ const used = usage.byGroup.get(group.id) ?? 0;
6741
+ if (used > group.maximum_quantity) return true;
6742
+ if (used < group.minimum_quantity) return true;
6743
+ }
6744
+ for (const slot of slots) {
6745
+ const used = usage.bySlot.get(slot) ?? 0;
6746
+ if (used > slot.modifierMax) return true;
6747
+ if (used > 0 && used < slot.modifierMin) return true;
6748
+ }
6749
+ return false;
6750
+ };
6751
+ const claimKey = (line) => {
6752
+ const subtotal = new Big(line.unit_subtotal).toString();
6753
+ if (line.tax_rate == null) return `${subtotal}|null`;
6754
+ return `${subtotal}|${new Big(line.tax_rate).toString()}`;
6755
+ };
6756
+ const sameOwnMoney = (line, money) => {
6757
+ for (const field of OWN_MONEY) if (!new Big(line[field]).eq(money[field])) return false;
6758
+ if (line.tax_rate == null) return false;
6759
+ return new Big(line.tax_rate).eq(money.tax_rate);
6760
+ };
6761
+ /** True when the line's own money is exactly what the menu yields for this
6762
+ * slot: the slot's price and tax, with 0 to `quantity` charged units
6763
+ * (charge_above shares free units between siblings). It only narrows which
6764
+ * slots a line may take. Nothing from the cart is ever copied into the
6765
+ * expected tree: the final comparison is still exact. */
6766
+ const slotYieldsLine = (lookup, line, ordered, slot) => {
6767
+ const quantity = line.quantity;
6768
+ if (quantity <= 0) return false;
6769
+ if (quantity > slot.modifierMax) return false;
6770
+ if (quantity > slot.group.maximum_quantity) return false;
6771
+ const listed = lookup.productsById[slot.modifierId] ?? ordered;
6772
+ const pricing = modifierUnitPricing(listed, ordered, slot.group.id);
6773
+ const free = (resolvePricingModifier(listed, slot.group.id)?.charge_above_quantity ?? 0) + (slot.group.charge_above_quantity ?? 0);
6774
+ const unit = new Big(pricing.price);
6775
+ const config = {
6776
+ quantity,
6777
+ taxRate: pricing.taxRate
6778
+ };
6779
+ const inclusive = { taxInclusive: pricing.isTaxInclusive };
6780
+ const fewest = Math.max(0, quantity - free);
6781
+ for (let charged = fewest; charged <= quantity; charged++) {
6782
+ const unitSubtotal = unit.times(charged).div(quantity);
6783
+ const money = applyUnitMoney({
6784
+ ...config,
6785
+ ...inclusive,
6786
+ unitSubtotal
6787
+ });
6788
+ if (sameOwnMoney(line, money)) return true;
6789
+ }
6790
+ return false;
6791
+ };
6792
+ /** Slots the line may take: those whose menu price it matches, else every
6793
+ * listed slot (the comparison then reports the price it cannot match). */
6794
+ const optionsFor = (lookup, line, ordered, listed) => {
6795
+ if (listed.length < 2) return listed;
6796
+ const yielding = listed.filter((slot) => {
6797
+ return slotYieldsLine(lookup, line, ordered, slot);
6798
+ });
6799
+ if (yielding.length > 0) return yielding;
6800
+ return listed;
6801
+ };
6802
+ /** Depth-first assignment of sibling lines to slots, in a fixed order, within
6803
+ * capacity, until every group and modifier limit holds. Twins (same shape and
6804
+ * same claimed price) are interchangeable, so they are forced into
6805
+ * non-decreasing option order. At most SEARCH_BUDGET steps of O(slots). */
6806
+ const searchAssignment = (plans, groups, slots) => {
6807
+ const total = plans.length;
6808
+ const usage = emptyUsage();
6809
+ const chosen = [];
6810
+ const picked = [];
6811
+ const cursor = [0];
6812
+ let depth = 0;
6813
+ let steps = 0;
6814
+ while (depth >= 0) {
6815
+ steps += 1;
6816
+ if (steps > SEARCH_BUDGET) return null;
6817
+ if (depth === total) {
6818
+ if (!limitsBroken(groups, slots, usage)) return chosen;
6819
+ depth -= 1;
6820
+ addUsage(usage, chosen[depth], -plans[depth].line.quantity);
6821
+ continue;
6822
+ }
6823
+ const plan = plans[depth];
6824
+ const quantity = plan.line.quantity;
6825
+ let found = -1;
6826
+ for (let next = cursor[depth]; next < plan.options.length; next++) {
6827
+ if (!hasRoom(usage, plan.options[next], quantity)) continue;
6828
+ found = next;
6829
+ break;
6830
+ }
6831
+ if (found < 0) {
6832
+ depth -= 1;
6833
+ if (depth < 0) break;
6834
+ addUsage(usage, chosen[depth], -plans[depth].line.quantity);
6835
+ continue;
6836
+ }
6837
+ cursor[depth] = found + 1;
6838
+ picked[depth] = found;
6839
+ chosen[depth] = plan.options[found];
6840
+ addUsage(usage, chosen[depth], quantity);
6841
+ depth += 1;
6842
+ cursor[depth] = plans[depth]?.twin === true ? picked[depth - 1] : 0;
6843
+ }
6844
+ return null;
6845
+ };
6846
+ /** Used when no assignment satisfies every limit: first slot with room, so the
6847
+ * repaired tree and the broken-rules flag still come out deterministic. */
6848
+ const greedyAssignment = (plans) => {
6849
+ const usage = emptyUsage();
6850
+ const chosen = [];
6851
+ let overCapacity = false;
6852
+ for (const plan of plans) {
6853
+ const quantity = plan.line.quantity;
6854
+ const fits = (slot) => hasRoom(usage, slot, quantity);
6855
+ let slot = plan.options.find(fits) ?? plan.listed.find(fits);
6856
+ if (!slot) {
6857
+ slot = plan.listed[0];
6858
+ overCapacity = true;
6859
+ }
6860
+ addUsage(usage, slot, quantity);
6861
+ chosen.push(slot);
6862
+ }
6863
+ return {
6864
+ chosen,
6865
+ overCapacity
6866
+ };
6867
+ };
6868
+ const planKids = (lookup, groups, byKey, kids, prefix, state) => {
6869
+ const plans = [];
6870
+ let previous = "";
6871
+ for (const child of kids) {
6872
+ if (pathVisits(prefix, child.id)) continue;
6873
+ const ordered = lookup.productsById[child.id];
6874
+ const listings = listingsFor(lookup, groups, child.id);
6875
+ if (!ordered || listings.length === 0) continue;
6876
+ const listed = [];
6877
+ for (const item of listings) {
6878
+ const slot = byKey.get(slotKey(item.group.id, item.modifierId));
6879
+ if (slot) listed.push(slot);
6880
+ }
6881
+ if (listed.length === 0) continue;
6882
+ const signature = `${selectionKey(child, state.childrenByParent, state.keyCache)}|${claimKey(child)}`;
6883
+ const options = optionsFor(lookup, child, ordered, listed);
6884
+ const twin = signature === previous;
6885
+ plans.push({
6886
+ line: child,
6887
+ ordered,
6888
+ listed,
6889
+ options,
6890
+ twin
6891
+ });
6892
+ previous = signature;
6893
+ }
6894
+ return plans;
6895
+ };
6896
+ const placeChildren = (lookup, parent, parentLine, prefix, state) => {
6897
+ const groups = parentGroups(lookup, parent);
6898
+ const slots = buildSlots(lookup, groups);
6899
+ const byKey = /* @__PURE__ */ new Map();
6900
+ for (const slot of slots) byKey.set(slotKey(slot.group.id, slot.modifierId), slot);
6901
+ const kids = (state.childrenByParent.get(parentLine.uid) ?? []).slice();
6902
+ kids.sort((left, right) => {
6903
+ const a = selectionKey(left, state.childrenByParent, state.keyCache);
6904
+ const b = selectionKey(right, state.childrenByParent, state.keyCache);
6905
+ const byShape = a.localeCompare(b);
6906
+ if (byShape !== 0) return byShape;
6907
+ return claimKey(left).localeCompare(claimKey(right));
6908
+ });
6909
+ const plans = planKids(lookup, groups, byKey, kids, prefix, state);
6910
+ let assignment = [];
6911
+ if (plans.length > 0) assignment = searchAssignment(plans, groups, slots);
6912
+ if (!assignment) {
6913
+ const fallback = greedyAssignment(plans);
6914
+ assignment = fallback.chosen;
6915
+ if (fallback.overCapacity) state.overCapacity = true;
6916
+ }
6917
+ const usage = emptyUsage();
6918
+ const placed = [];
6919
+ for (let index = 0; index < plans.length; index++) {
6920
+ const plan = plans[index];
6921
+ const chosen = assignment[index];
6922
+ const child = plan.line;
6923
+ const ordered = plan.ordered;
6924
+ const base = `${`${prefix}/group/${chosen.group.id}`}/modifier/${chosen.modifierId}`;
6925
+ let path = base;
6926
+ if (chosen.instances > 0) {
6927
+ path = `${base}/instance/${chosen.next}`;
6928
+ chosen.next += 1;
6929
+ }
6930
+ chosen.instances += 1;
6931
+ addUsage(usage, chosen, child.quantity);
6932
+ state.paths[path] = child.quantity;
6933
+ const listed = lookup.productsById[chosen.modifierId] ?? ordered;
6934
+ const pricing = modifierUnitPricing(listed, ordered, chosen.group.id);
6935
+ const n1 = {
6936
+ product: ordered,
6937
+ quantity: child.quantity,
6938
+ path
6939
+ };
6940
+ const n2 = {
6941
+ children: [],
6942
+ listed,
6943
+ group: chosen.group
6944
+ };
6945
+ const n3 = {
6946
+ unitPrice: new Big(0),
6947
+ taxRate: pricing.taxRate
6948
+ };
6949
+ const n4 = { taxInclusive: pricing.isTaxInclusive };
6950
+ const node = {
6951
+ ...n1,
6952
+ ...n2,
6953
+ ...n3,
6954
+ ...n4
6955
+ };
6956
+ node.children = placeChildren(lookup, ordered, child, path, state);
6957
+ placed.push(node);
6958
+ }
6959
+ noteRules(state, limitsBroken(groups, slots, usage));
6960
+ billGroups(placed);
6961
+ return placed;
6962
+ };
6963
+ const billGroups = (nodes) => {
6964
+ const byGroup = /* @__PURE__ */ new Map();
6965
+ for (const node of nodes) {
6966
+ if (!node.group) continue;
6967
+ const list = byGroup.get(node.group.id) ?? [];
6968
+ list.push(node);
6969
+ byGroup.set(node.group.id, list);
6970
+ }
6971
+ for (const list of byGroup.values()) {
6972
+ const group = list[0]?.group;
6973
+ if (group) billGroup(group, list);
6974
+ }
6975
+ };
6976
+ /** Menu order, then instance order. One modifier's free units are shared,
6977
+ * so a second sibling line is not free just because it is its own line. */
6978
+ const billGroup = (group, nodes) => {
6979
+ const groupChargeAbove = group.charge_above_quantity ?? 0;
6980
+ let chargedCount = 0;
6981
+ const consumed = /* @__PURE__ */ new Map();
6982
+ const ordered = [];
6983
+ for (const modifierId of group.modifier_ids) for (const node of nodes) if (node.listed?.id === modifierId) ordered.push(node);
6984
+ for (const node of ordered) {
6985
+ if (node.quantity <= 0 || !node.listed || !node.group) continue;
6986
+ const entry = resolvePricingModifier(node.listed, node.group.id);
6987
+ const unit = new Big(modifierUnitPricing(...[
6988
+ node.listed,
6989
+ node.product,
6990
+ node.group.id
6991
+ ]).price);
6992
+ const allowance0 = entry?.charge_above_quantity ?? 0;
6993
+ const id = node.listed.id;
6994
+ const taken = consumed.get(id) ?? 0;
6995
+ const allowance = Math.max(0, allowance0 - taken);
6996
+ let remaining = Math.max(0, node.quantity - allowance);
6997
+ consumed.set(id, taken + node.quantity);
6998
+ let linePrice = new Big(0);
6999
+ while (remaining > 0) {
7000
+ if (chargedCount >= groupChargeAbove) linePrice = linePrice.plus(unit);
7001
+ chargedCount += 1;
7002
+ remaining -= 1;
7003
+ }
7004
+ node.unitPrice = node.quantity > 0 ? linePrice.div(node.quantity) : unit;
7005
+ }
7006
+ };
7007
+ const applyUnitMoney = (config) => {
7008
+ const qty = new Big(config.quantity);
7009
+ const net = config.unitSubtotal;
7010
+ let unitTax = net.times(config.taxRate);
7011
+ let unitTotal = net.plus(unitTax);
7012
+ if (config.taxInclusive) {
7013
+ const divisor = new Big(1).plus(config.taxRate);
7014
+ unitTax = net.times(config.taxRate).div(divisor);
7015
+ unitTotal = net;
7016
+ }
7017
+ const r1 = {
7018
+ tax_rate: config.taxRate,
7019
+ discount_rate: 0
7020
+ };
7021
+ const r2 = { unit_subtotal: config.unitSubtotal.toNumber() };
7022
+ const r3 = {
7023
+ unit_discount: 0,
7024
+ unit_tax_total: unitTax.toNumber()
7025
+ };
7026
+ const r4 = { unit_total: unitTotal.toNumber() };
7027
+ const r5 = { subtotal: config.unitSubtotal.times(qty).toNumber() };
7028
+ const r6 = {
7029
+ discount_total: 0,
7030
+ tax_total: unitTax.times(qty).toNumber()
7031
+ };
7032
+ const r7 = { total: unitTotal.times(qty).toNumber() };
7033
+ return {
7034
+ ...r1,
7035
+ ...r2,
7036
+ ...r3,
7037
+ ...r4,
7038
+ ...r5,
7039
+ ...r6,
7040
+ ...r7
7041
+ };
7042
+ };
7043
+ const emptyGroup = () => {
7044
+ const g1 = {
7045
+ unit_group_subtotal: 0,
7046
+ unit_group_discount: 0
7047
+ };
7048
+ const g2 = {
7049
+ unit_group_tax_total: 0,
7050
+ unit_group_total: 0
7051
+ };
7052
+ const g3 = {
7053
+ group_subtotal: 0,
7054
+ group_discount: 0
7055
+ };
7056
+ const g4 = {
7057
+ group_tax_total: 0,
7058
+ group_total: 0
7059
+ };
7060
+ return {
7061
+ ...g1,
7062
+ ...g2,
7063
+ ...g3,
7064
+ ...g4
7065
+ };
7066
+ };
7067
+ const displayName = (text, locale) => {
7068
+ if (text[locale]) return text[locale];
7069
+ return Object.values(text)[0] ?? "";
7070
+ };
7071
+ const emit = (node, parentUid, parentId, locale, out) => {
7072
+ const mIn1 = {
7073
+ unitSubtotal: node.unitPrice,
7074
+ quantity: node.quantity
7075
+ };
7076
+ const mIn2 = {
7077
+ taxRate: node.taxRate,
7078
+ taxInclusive: node.taxInclusive
7079
+ };
7080
+ const money = applyUnitMoney({
7081
+ ...mIn1,
7082
+ ...mIn2
7083
+ });
7084
+ const imageUrl = node.product.image_url ?? void 0;
7085
+ const imagePatch = imageUrl ? { image_url: imageUrl } : {};
7086
+ const catalog = {
7087
+ metadata: { ...node.product.metadata ?? {} },
7088
+ bundled_items: [...node.product.bundled_items ?? []]
7089
+ };
7090
+ const r1 = {
7091
+ type: "PRODUCT",
7092
+ uid: randomUUID()
7093
+ };
7094
+ const r2 = {
7095
+ id: node.product.id,
7096
+ parent_uid: parentUid,
7097
+ parent_id: parentId
7098
+ };
7099
+ const r3 = {
7100
+ name: displayName(node.product.name, locale),
7101
+ quantity: node.quantity
7102
+ };
7103
+ const r4 = {
7104
+ redeem: [],
7105
+ ...imagePatch,
7106
+ ...catalog,
7107
+ ...money
7108
+ };
7109
+ const line = {
7110
+ ...r1,
7111
+ ...r2,
7112
+ ...r3,
7113
+ ...r4,
7114
+ ...emptyGroup()
7115
+ };
7116
+ out.push(line);
7117
+ for (const child of node.children) emit(child, line.uid, line.id, locale, out);
7118
+ let sub = new Big(line.subtotal);
7119
+ let tax = new Big(line.tax_total);
7120
+ let tot = new Big(line.total);
7121
+ let childSub = new Big(0);
7122
+ let childTax = new Big(0);
7123
+ let childTot = new Big(0);
7124
+ for (const child of out) {
7125
+ if (child.parent_uid !== line.uid) continue;
7126
+ childSub = childSub.plus(child.group_subtotal);
7127
+ childTax = childTax.plus(child.group_tax_total);
7128
+ childTot = childTot.plus(child.group_total);
7129
+ }
7130
+ const qty = new Big(line.quantity);
7131
+ sub = sub.plus(childSub.times(qty));
7132
+ tax = tax.plus(childTax.times(qty));
7133
+ tot = tot.plus(childTot.times(qty));
7134
+ line.group_subtotal = sub.toNumber();
7135
+ line.group_tax_total = tax.toNumber();
7136
+ line.group_total = tot.toNumber();
7137
+ line.group_discount = 0;
7138
+ line.unit_group_subtotal = sub.div(qty).toNumber();
7139
+ line.unit_group_tax_total = tax.div(qty).toNumber();
7140
+ line.unit_group_total = tot.div(qty).toNumber();
7141
+ line.unit_group_discount = 0;
7142
+ };
7143
+ const rootNode = (lookup, root, state) => {
7144
+ const product = lookup.productsById[root.id];
7145
+ if (!product) return null;
7146
+ const max = product.pricing.maximum_quantity;
7147
+ const min = product.pricing.minimum_quantity;
7148
+ noteRules(state, root.quantity > max || root.quantity < min);
7149
+ const path = `/${product.id}`;
7150
+ state.paths[path] = root.quantity;
7151
+ const children = placeChildren(lookup, product, root, path, state);
7152
+ const r1 = {
7153
+ product,
7154
+ quantity: root.quantity,
7155
+ path,
7156
+ children
7157
+ };
7158
+ const r2 = {
7159
+ listed: null,
7160
+ group: null
7161
+ };
7162
+ const r3 = { unitPrice: new Big(product.pricing.price) };
7163
+ const r4 = { taxRate: product.pricing.tax_rate };
7164
+ const r5 = { taxInclusive: product.pricing.is_tax_inclusive };
7165
+ return {
7166
+ ...r1,
7167
+ ...r2,
7168
+ ...r3,
7169
+ ...r4,
7170
+ ...r5
7171
+ };
7172
+ };
7173
+ /** Place cart lines on menu slots and price them from the menu.
7174
+ * The tree is built from product id, parent, and quantity. A line's claimed
7175
+ * own price is read for one purpose: to rank which listed slots it may take
7176
+ * (see slotYieldsLine). The expected money is always the menu's for the
7177
+ * chosen slot, so a claim the menu cannot yield stays a mismatch.
7178
+ * A quantity above 1 stays one line. Extra sibling lines reuse a slot
7179
+ * only while its max still has room, at `/instance/2`, `/instance/3`, ….
7180
+ * Indices are assigned here. A forged index in the cart is not an input. */
7181
+ const placeCartLines = (root, lines, lookup, locale) => {
7182
+ const childrenByParent = /* @__PURE__ */ new Map();
7183
+ for (const line of lines) {
7184
+ if (line.parent_uid == null) continue;
7185
+ const list = childrenByParent.get(line.parent_uid) ?? [];
7186
+ list.push(line);
7187
+ childrenByParent.set(line.parent_uid, list);
7188
+ }
7189
+ const state = {
7190
+ paths: {},
7191
+ overCapacity: false,
7192
+ rulesBroken: false,
7193
+ childrenByParent,
7194
+ keyCache: /* @__PURE__ */ new Map()
7195
+ };
7196
+ const node = rootNode(lookup, root, state);
7197
+ const out = [];
7198
+ if (node) emit(node, null, null, locale, out);
7199
+ if (state.overCapacity) state.rulesBroken = true;
7200
+ return {
7201
+ paths: state.paths,
7202
+ overCapacity: state.overCapacity,
7203
+ rulesBroken: state.rulesBroken,
7204
+ lines: out
7205
+ };
7206
+ };
7207
+ //#endregion
6606
7208
  //#region src/order/validate.ts
6607
7209
  const PRICE_FIELDS = [
6608
7210
  "unit_subtotal",
@@ -6661,11 +7263,34 @@ const rateStr = (n) => {
6661
7263
  if (n == null) return "null";
6662
7264
  return bigStr(n);
6663
7265
  };
7266
+ const structureKey = (lines, line) => {
7267
+ const nested = lines.filter((child) => child.parent_uid === line.uid).map((child) => structureKey(lines, child));
7268
+ nested.sort((left, right) => left.localeCompare(right));
7269
+ return `${line.id}x${line.quantity}(${nested.join(",")})`;
7270
+ };
7271
+ /** Sorted by id as before. Only same-id siblings are told apart further:
7272
+ * by nested shape, then unit price, so each pairs with its own expectation. */
6664
7273
  const childrenOf = (lines, parentUid) => {
6665
7274
  const children = lines.filter((line) => {
6666
7275
  return line.parent_uid === parentUid;
6667
7276
  });
6668
- children.sort((a, b) => a.id.localeCompare(b.id));
7277
+ const shapes = /* @__PURE__ */ new Map();
7278
+ const shapeOf = (line) => {
7279
+ const cached = shapes.get(line.uid);
7280
+ if (cached !== void 0) return cached;
7281
+ const shape = structureKey(lines, line);
7282
+ shapes.set(line.uid, shape);
7283
+ return shape;
7284
+ };
7285
+ children.sort((a, b) => {
7286
+ const byId = a.id.localeCompare(b.id);
7287
+ if (byId !== 0) return byId;
7288
+ const byShape = shapeOf(a).localeCompare(shapeOf(b));
7289
+ if (byShape !== 0) return byShape;
7290
+ const bySubtotal = new Big(a.unit_subtotal).cmp(b.unit_subtotal);
7291
+ if (bySubtotal !== 0) return bySubtotal;
7292
+ return new Big(a.unit_tax_total).cmp(b.unit_tax_total);
7293
+ });
6669
7294
  return children;
6670
7295
  };
6671
7296
  const collectUnmatchedModifiers = (lines, lookup, parentProductId, parentUid, out) => {
@@ -6908,10 +7533,17 @@ const validateProductRoot = (root, lines, lookup, locale, out) => {
6908
7533
  if (status === "OUT_OF_STOCK") pushLineIssue(out, root.uid, { code: "OUT_OF_STOCK" });
6909
7534
  if (!isOnSchedule(product.availability.schedules)) pushLineIssue(out, root.uid, { code: "OUTSIDE_HOURS" });
6910
7535
  collectUnmatchedModifiers(lines, lookup, root.id, root.uid, out);
6911
- const snapshot = fromOrderLines(root, lines, lookup);
6912
- const session = createSession(lookup, root.id, { snapshot });
6913
- if (session.validate() !== "VALID") pushLineIssue(out, root.uid, { code: "MODIFIER_RULES_INVALID" });
6914
- const expected = toOrderLines(session.root, locale);
7536
+ let expected;
7537
+ if (hasRepeatedProductSibling(lines, root.uid)) {
7538
+ const placed = placeCartLines(root, lines, lookup, locale);
7539
+ if (placed.rulesBroken) pushLineIssue(out, root.uid, { code: "MODIFIER_RULES_INVALID" });
7540
+ expected = placed.lines;
7541
+ } else {
7542
+ const snapshot = fromOrderLines(root, lines, lookup);
7543
+ const session = createSession(lookup, root.id, { snapshot });
7544
+ if (session.validate() !== "VALID") pushLineIssue(out, root.uid, { code: "MODIFIER_RULES_INVALID" });
7545
+ expected = toOrderLines(session.root, locale);
7546
+ }
6915
7547
  const expectedRoot = expected.find((line) => line.parent_uid == null);
6916
7548
  if (!expectedRoot) {
6917
7549
  pushLineIssue(out, root.uid, { code: "STRUCTURE_MISMATCH" });
@@ -7234,6 +7866,17 @@ const collectOrderLineDiffs = (input) => {
7234
7866
  continue;
7235
7867
  }
7236
7868
  try {
7869
+ if (hasRepeatedProductSibling(input.lines, root.uid)) {
7870
+ const placed = placeCartLines(root, input.lines, lookup, locale);
7871
+ const next = placed.rulesBroken ? null : placed.lines;
7872
+ diffs.push({
7873
+ uid: root.uid,
7874
+ previous,
7875
+ next,
7876
+ issues
7877
+ });
7878
+ continue;
7879
+ }
7237
7880
  const snapshot = fromOrderLines(root, input.lines, lookup);
7238
7881
  const next = toOrderLines(createSession(lookup, root.id, { snapshot }).root, locale);
7239
7882
  diffs.push({
@@ -7800,16 +8443,6 @@ const countCartProducts = (lines, includeModifiers) => {
7800
8443
  return line.type === "PRODUCT" || line.type == null;
7801
8444
  }).reduce((sum, line) => sum + line.quantity, 0);
7802
8445
  };
7803
- const getLineTagValues = (lines, tag) => {
7804
- if (!tag) return [];
7805
- const values = [];
7806
- for (const line of lines ?? []) {
7807
- const value = line.tags?.[tag];
7808
- if (typeof value === "undefined") continue;
7809
- values.push(value);
7810
- }
7811
- return values;
7812
- };
7813
8446
  //#endregion
7814
8447
  //#region src/rules/absoluteDateMatcher.ts
7815
8448
  var import_dayjs_min = /* @__PURE__ */ __toESM(require_dayjs_min(), 1);
@@ -8414,6 +9047,72 @@ const matchRelativeDateOperator = (condition, matcher, referenceDate) => {
8414
9047
  }
8415
9048
  };
8416
9049
  //#endregion
9050
+ //#region src/rules/tagMatcher.ts
9051
+ const NEGATED_TO_POSITIVE = {
9052
+ "not equal to": "equal to",
9053
+ "is not": "is"
9054
+ };
9055
+ const SET_OPERATORS = [
9056
+ "is any of",
9057
+ "is all of",
9058
+ "is none of"
9059
+ ];
9060
+ const isComparable = (kind, value) => {
9061
+ if (kind === "string") return typeof value === "string";
9062
+ if (kind === "boolean") return typeof value === "boolean";
9063
+ return typeof value === "number";
9064
+ };
9065
+ const normalize = (value, caseInsensitive) => {
9066
+ if (typeof value === "string" && caseInsensitive) return value.toLowerCase();
9067
+ return value;
9068
+ };
9069
+ const matchSet = (condition, kind, elements) => {
9070
+ const { operator, value } = condition;
9071
+ if (!Array.isArray(value)) throw new Error("Invalid tag set value");
9072
+ const caseInsensitive = getCaseInsensitive(condition);
9073
+ const wanted = new Set(value.map((item) => normalize(item, caseInsensitive)));
9074
+ const present = new Set(elements.map((item) => normalize(item, caseInsensitive)));
9075
+ const hasAny = elements.length > 0;
9076
+ switch (operator) {
9077
+ case "is any of": return [...present].some((item) => wanted.has(item));
9078
+ case "is all of": return [...wanted].every((item) => present.has(item));
9079
+ case "is none of": return !hasAny || ![...present].some((item) => wanted.has(item));
9080
+ default: throw new Error(`Operator ${operator} not supported in ${kind} tags`);
9081
+ }
9082
+ };
9083
+ /**
9084
+ * Evaluates one line's tag value against a tag condition.
9085
+ *
9086
+ * - Scalars use the primitive's matcher unchanged. A value of another type
9087
+ * never matches.
9088
+ * - Lists use "any element matches" for positive operators and "no element
9089
+ * matches the positive form" for negated ones. An empty list matches
9090
+ * negated operators and set operator "is none of", never the others.
9091
+ * - Set operators treat a scalar as a one-element set.
9092
+ */
9093
+ const matchTagLine = (condition, kind, raw, matchScalar) => {
9094
+ const { operator } = condition;
9095
+ const isList = Array.isArray(raw);
9096
+ const items = isList ? raw : [raw];
9097
+ const comparable = items.filter((item) => isComparable(kind, item));
9098
+ if (SET_OPERATORS.includes(operator)) {
9099
+ if (kind === "boolean" || kind === "currency") return false;
9100
+ if (items.length > 0 && comparable.length === 0) return false;
9101
+ return matchSet(condition, kind, comparable);
9102
+ }
9103
+ const match = matchScalar;
9104
+ if (!isList) return comparable.length > 0 ? match(condition, raw) : false;
9105
+ const positive = NEGATED_TO_POSITIVE[operator];
9106
+ if (items.length === 0) return typeof positive === "string";
9107
+ if (comparable.length === 0) return false;
9108
+ if (typeof positive === "undefined") return comparable.some((item) => match(condition, item));
9109
+ const positiveCondition = {
9110
+ ...condition,
9111
+ operator: positive
9112
+ };
9113
+ return !comparable.some((item) => match(positiveCondition, item));
9114
+ };
9115
+ //#endregion
8417
9116
  //#region src/rules/RulesEngine.ts
8418
9117
  var RulesEngine = class {
8419
9118
  order;
@@ -8450,20 +9149,20 @@ var RulesEngine = class {
8450
9149
  if (!("tag" in condition) || !condition.tag) throw new Error("Invalid tag");
8451
9150
  return condition.tag;
8452
9151
  };
8453
- matchOrderProductTag = (condition, match) => {
9152
+ matchOrderProductTag = (condition, kind, matchScalar) => {
8454
9153
  const order = this.getDependency("order", condition.type);
8455
9154
  const tag = this.getProductTag(condition);
8456
- const values = getLineTagValues(order.lines, tag);
8457
- for (const value of values) if (match(value)) return true;
9155
+ for (const line of order.lines ?? []) {
9156
+ const raw = line.tags?.[tag];
9157
+ if (typeof raw === "undefined") continue;
9158
+ if (matchTagLine(condition, kind, raw, matchScalar)) return true;
9159
+ }
8458
9160
  return false;
8459
9161
  };
8460
9162
  assertString = (condition) => {
8461
9163
  const { primitive, type } = condition;
8462
9164
  assert(primitive === "string", primitiveError("string"));
8463
- if (type === "Order includes product with tag") return this.matchOrderProductTag(condition, (value) => {
8464
- if (typeof value !== "string") return false;
8465
- return orderIncludesProductWithTag(condition, value);
8466
- });
9165
+ if (type === "Order includes product with tag") return this.matchOrderProductTag(condition, "string", orderIncludesProductWithTag);
8467
9166
  if (type === "Request path") {
8468
9167
  const monitoring = this.getDependency("monitoring", type);
8469
9168
  return requestPath(condition, monitoring.path);
@@ -8489,10 +9188,7 @@ var RulesEngine = class {
8489
9188
  const line = getLineByProductId(order.lines, productId);
8490
9189
  return orderIncludesProduct(condition, line?.quantity);
8491
9190
  }
8492
- if (type === "Order includes product with tag") return this.matchOrderProductTag(condition, (value) => {
8493
- if (typeof value !== "number") return false;
8494
- return orderIncludesProductWithTag$1(condition, value);
8495
- });
9191
+ if (type === "Order includes product with tag") return this.matchOrderProductTag(condition, "number", orderIncludesProductWithTag$1);
8496
9192
  if (type === "Accumulated events") {
8497
9193
  const monitoring = this.getDependency("monitoring", type);
8498
9194
  const window = windowMinutes(condition);
@@ -8525,10 +9221,7 @@ var RulesEngine = class {
8525
9221
  const isAnonymous = this.getDependency("customer", type).is_anonymous;
8526
9222
  return isAnonymousCustomer(condition, isAnonymous);
8527
9223
  }
8528
- if (type === "Order includes product with tag") return this.matchOrderProductTag(condition, (value) => {
8529
- if (typeof value !== "boolean") return false;
8530
- return orderIncludesProductWithTag$3(condition, value);
8531
- });
9224
+ if (type === "Order includes product with tag") return this.matchOrderProductTag(condition, "boolean", orderIncludesProductWithTag$3);
8532
9225
  if (type === "Payment is mixed") {
8533
9226
  const payment = this.getDependency("payment", type);
8534
9227
  return paymentIsMixed(condition, payment.is_mixed);
@@ -8546,10 +9239,7 @@ var RulesEngine = class {
8546
9239
  const order = this.getDependency("order", type);
8547
9240
  return orderTotal(condition, order.total);
8548
9241
  }
8549
- if (type === "Order includes product with tag") return this.matchOrderProductTag(condition, (value) => {
8550
- if (typeof value !== "number") return false;
8551
- return orderIncludesProductWithTag$2(condition, value);
8552
- });
9242
+ if (type === "Order includes product with tag") return this.matchOrderProductTag(condition, "currency", orderIncludesProductWithTag$2);
8553
9243
  throw new Error(`Condition ${type} not supported in currency matcher`);
8554
9244
  };
8555
9245
  assertEnum = (condition) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fire-spark/core",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "Fire Spark headless SDK",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -20,8 +20,8 @@
20
20
  "big.js": "6.2.1",
21
21
  "dayjs": "1.11.9",
22
22
  "@types/big.js": "6.2.2",
23
- "@fire-spark/payments": "^0.11.1",
24
- "@fire-spark/types": "^0.11.1"
23
+ "@fire-spark/payments": "^0.12.0",
24
+ "@fire-spark/types": "^0.12.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "tsdown": "^0.22.14",