@fire-spark/core 0.11.1 → 0.12.1
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.
- package/dist/index.d.ts +13 -3
- package/dist/index.js +750 -42
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Money, PaymentAdapter, PaymentCollectAt, PaymentContext, PaymentResult, PaymentStorage, PaymentUI, RedeemLike, RedeemSource, isRealPaymentMethod, orderRedeems } from "@fire-spark/payments";
|
|
1
|
+
import { Money, PaymentAdapter, PaymentCollectAt, PaymentContext, PaymentResult, PaymentReversal, PaymentStorage, PaymentUI, RedeemLike, RedeemSource, isRealPaymentMethod, orderRedeems } from "@fire-spark/payments";
|
|
2
2
|
import { AndConditions, CheckoutMode, CheckoutResolved, CmsBlock, Condition, Customer, CustomerIdentifyInput, CustomerRegisterInput, CustomerUpdateInput, Fulfillment, FulfillmentListInput, GroupDisplayType, KioskDeviceConfig, KioskDeviceOverrides, KioskHealthBatchInput, KioskHealthBatchResult, KioskHeartbeatInput, KioskHeartbeatResult, KioskRealtimeToken, MatchResult, MatchableRule, Menu, Menu as Menu$1, MenuLookup, MenuLookup as MenuLookup$1, MerchantPayment, ModifierControlType, ModifierGroup, OrConditions, Order as Order$1, Order as Order$2, OrderCancelInput, OrderCancelInput as OrderCancelInput$1, OrderCancelType, OrderCancelType as OrderCancelType$1, OrderDeviceListInput, OrderDeviceListResult, OrderFulfillment, OrderInsertInput, OrderLine, OrderListInput, OrderRecordPaymentInput, OrderRedeem, OrderRedemptionStep, OrderReorderInput, OrderTotals, OrderUpdateInput, OrderValidateInput, OrderValidateOutput, PaymentCollectAt as PaymentCollectAt$1, PaymentIntentPutInput, PaymentMethod, PaymentProvider, PaymentTender, PaymentTenderInput, PlaceOrderResult, PosCatalog, PosFloorInput, PosFloorOccupancy, PosFloorPlan, PosFloorRealtimeToken, PosHealthBatchInput, PosHealthBatchResult, PosHeartbeatInput, PosHeartbeatResult, PosKioskOrder, PosKioskSettleInput, PosKioskSettleResult, PosOrderInput, PosOrderResult, PosTableAttachOrderInput, PosTableCloseInput, PosTableJoinInput, PosTableSeatInput, PosTableSeatReservationInput, PosTableSplitInput, PricingModifier, Product, Product as Product$1, Recommendation, RecurringDateCronResult, RecurringDateCronValues, RulesEngineOrderLine, RulesEngineParams, RulesEngineRuntime, Schedule, SdkLogContext, SdkLogErrorInfo, SdkLogLevel, SdkLogOptions, Store, StoreGetInput, StoreListInput, StoreNearbyInput, StorefrontAvailability, StorefrontMenu, StorefrontMenuScope, StorefrontMenuStreamOptions, StorefrontPaymentsListInput, StorefrontPreferences, TenderRefundInput, TenderReplaceInput, TenderVoidInput, Variant, VariantGroup } from "@fire-spark/types";
|
|
3
3
|
import { Big } from "big.js";
|
|
4
4
|
//#region src/productCustomization/catalog.utils.d.ts
|
|
@@ -167,6 +167,11 @@ type AuthConfigureBase = {
|
|
|
167
167
|
/** Default: exchange an IdP id_token (Firebase, …) for a storefront token. */
|
|
168
168
|
type AuthConfigureSubjectOptions = AuthConfigureBase & {
|
|
169
169
|
getSubjectToken: () => Promise<string>;
|
|
170
|
+
/**
|
|
171
|
+
* OAuth app alias (RFC 8693 `audience`). Required when several merchants
|
|
172
|
+
* share the identity provider (same issuer and audience).
|
|
173
|
+
*/
|
|
174
|
+
audience?: string;
|
|
170
175
|
getAccessToken?: never;
|
|
171
176
|
};
|
|
172
177
|
/**
|
|
@@ -176,11 +181,14 @@ type AuthConfigureSubjectOptions = AuthConfigureBase & {
|
|
|
176
181
|
type AuthConfigureAccessTokenOptions = AuthConfigureBase & {
|
|
177
182
|
getAccessToken: () => Promise<StorefrontAccessToken>;
|
|
178
183
|
getSubjectToken?: never;
|
|
184
|
+
audience?: never;
|
|
179
185
|
};
|
|
180
186
|
type AuthConfigureOptions = AuthConfigureSubjectOptions | AuthConfigureAccessTokenOptions;
|
|
181
187
|
type ExchangeOptions = {
|
|
182
188
|
baseUrl?: string;
|
|
183
189
|
fetchImpl?: typeof fetch;
|
|
190
|
+
/** OAuth app alias (RFC 8693 `audience`) when the provider is shared. */
|
|
191
|
+
audience?: string;
|
|
184
192
|
};
|
|
185
193
|
//#endregion
|
|
186
194
|
//#region src/auth/auth.d.ts
|
|
@@ -313,7 +321,7 @@ type PaymentRunOptions = {
|
|
|
313
321
|
*/
|
|
314
322
|
declare const runPaymentAttempt: (adapter: PaymentAdapter, ctx: PaymentContext, options?: PaymentRunOptions) => Promise<PaymentResult>;
|
|
315
323
|
type ReversalOutcome = {
|
|
316
|
-
kind:
|
|
324
|
+
kind: PaymentReversal;
|
|
317
325
|
result: PaymentResult;
|
|
318
326
|
};
|
|
319
327
|
/** Undo a charge after a failed injection or an incomplete split payment. */
|
|
@@ -422,7 +430,7 @@ type OrderCancelResult = {
|
|
|
422
430
|
path: "cancel" | "refund";
|
|
423
431
|
/** What the device did with each charge it voided. */
|
|
424
432
|
outcomes: ReversalOutcome[];
|
|
425
|
-
/** Tender ids the device
|
|
433
|
+
/** Tender ids neither the device nor the server reversed yet. */
|
|
426
434
|
pending: string[];
|
|
427
435
|
};
|
|
428
436
|
//#endregion
|
|
@@ -1129,6 +1137,8 @@ type DeunaOrderStatus = {
|
|
|
1129
1137
|
order_token: string;
|
|
1130
1138
|
status: string;
|
|
1131
1139
|
transaction_id: string | null;
|
|
1140
|
+
/** Processor DEUNA charged with, when it reports one. */
|
|
1141
|
+
processor?: string | null;
|
|
1132
1142
|
error: {
|
|
1133
1143
|
code: string | null;
|
|
1134
1144
|
message: string | null;
|
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,
|
|
@@ -1888,7 +1888,8 @@ const exchangeSubjectToken = async (subjectToken, options = {}) => {
|
|
|
1888
1888
|
body: JSON.stringify({
|
|
1889
1889
|
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
1890
1890
|
subject_token: subjectToken,
|
|
1891
|
-
subject_token_type: "urn:ietf:params:oauth:token-type:id_token"
|
|
1891
|
+
subject_token_type: "urn:ietf:params:oauth:token-type:id_token",
|
|
1892
|
+
...options.audience ? { audience: options.audience } : {}
|
|
1892
1893
|
})
|
|
1893
1894
|
});
|
|
1894
1895
|
if (!response.ok) {
|
|
@@ -1907,6 +1908,7 @@ const configure = (options) => {
|
|
|
1907
1908
|
state = {
|
|
1908
1909
|
getSubjectToken: options.getSubjectToken ?? null,
|
|
1909
1910
|
getDirectToken: options.getAccessToken ?? null,
|
|
1911
|
+
audience: options.audience,
|
|
1910
1912
|
baseUrl: resolveBaseUrl(options.baseUrl),
|
|
1911
1913
|
fetchImpl: options.fetchImpl ?? fetch,
|
|
1912
1914
|
tokenSkewMs: options.tokenSkewMs ?? DEFAULT_TOKEN_SKEW_MS,
|
|
@@ -1928,7 +1930,8 @@ const issueToken = async (current) => {
|
|
|
1928
1930
|
if (!current.getSubjectToken) throw new Error("auth.configure() needs getSubjectToken or getAccessToken");
|
|
1929
1931
|
return exchangeSubjectToken(await current.getSubjectToken(), {
|
|
1930
1932
|
baseUrl: current.baseUrl,
|
|
1931
|
-
fetchImpl: current.fetchImpl
|
|
1933
|
+
fetchImpl: current.fetchImpl,
|
|
1934
|
+
audience: current.audience
|
|
1932
1935
|
});
|
|
1933
1936
|
};
|
|
1934
1937
|
const getAccessToken = async () => {
|
|
@@ -3390,6 +3393,14 @@ const REVERSED_RESULTS = ["REFUNDED", "CANCELLED"];
|
|
|
3390
3393
|
const isDeviceReversed = (reversal) => {
|
|
3391
3394
|
return REVERSED_RESULTS.includes(reversal.outcome.result.status);
|
|
3392
3395
|
};
|
|
3396
|
+
/**
|
|
3397
|
+
* The server ended the tender (`CANCELLED` / `REFUNDED`) in its answer:
|
|
3398
|
+
* a charge it reversed itself (`server` kind) is not pending.
|
|
3399
|
+
*/
|
|
3400
|
+
const isTenderSettled$1 = (order, tenderId) => {
|
|
3401
|
+
const tender = liveTenders(currentIntent(order.payment_intents)).find((item) => item.id === tenderId);
|
|
3402
|
+
return Boolean(tender && REVERSED_RESULTS.includes(tender.status));
|
|
3403
|
+
};
|
|
3393
3404
|
/** Body `tenders` entry for one device reversal. */
|
|
3394
3405
|
const toReportedTender = (reversal) => {
|
|
3395
3406
|
const { tender, outcome } = reversal;
|
|
@@ -4232,6 +4243,10 @@ const reversePayment = async (adapter, ctx, result, options = {}) => {
|
|
|
4232
4243
|
const clock = options.clock ?? defaultClock;
|
|
4233
4244
|
const now = options.now ?? new Date(clock.now());
|
|
4234
4245
|
let kind = (adapter.reversalFor ?? defaultReversalFor)(result, now);
|
|
4246
|
+
if (kind === "server") return {
|
|
4247
|
+
kind,
|
|
4248
|
+
result
|
|
4249
|
+
};
|
|
4235
4250
|
const operation = kind === "cancel" ? adapter.cancel : adapter.refund;
|
|
4236
4251
|
if (kind !== "manual" && !operation) kind = "manual";
|
|
4237
4252
|
if (kind === "manual" || !operation) {
|
|
@@ -6306,7 +6321,10 @@ var Order = class Order {
|
|
|
6306
6321
|
this.deleted = true;
|
|
6307
6322
|
this.notify();
|
|
6308
6323
|
const outcomes = reversals.map((reversal) => reversal.outcome);
|
|
6309
|
-
const pending = reversals.filter((reversal) =>
|
|
6324
|
+
const pending = reversals.filter((reversal) => {
|
|
6325
|
+
if (isDeviceReversed(reversal)) return false;
|
|
6326
|
+
return !isTenderSettled$1(next, reversal.tender.id);
|
|
6327
|
+
}).map((reversal) => reversal.tender.id);
|
|
6310
6328
|
return {
|
|
6311
6329
|
order: this.getOrder(),
|
|
6312
6330
|
type,
|
|
@@ -6603,6 +6621,608 @@ const milestoneSteps = (workflow, currentNodeIds) => {
|
|
|
6603
6621
|
};
|
|
6604
6622
|
};
|
|
6605
6623
|
//#endregion
|
|
6624
|
+
//#region src/order/placeCartLines.ts
|
|
6625
|
+
const selectionKey = (line, kidsOf, cache) => {
|
|
6626
|
+
const cached = cache.get(line.uid);
|
|
6627
|
+
if (cached) return cached;
|
|
6628
|
+
const nested = (kidsOf.get(line.uid) ?? []).map((child) => {
|
|
6629
|
+
return selectionKey(child, kidsOf, cache);
|
|
6630
|
+
});
|
|
6631
|
+
nested.sort((left, right) => left.localeCompare(right));
|
|
6632
|
+
const key = `${line.id}x${line.quantity}(${nested.join(",")})`;
|
|
6633
|
+
cache.set(line.uid, key);
|
|
6634
|
+
return key;
|
|
6635
|
+
};
|
|
6636
|
+
const parentGroups = (lookup, product) => {
|
|
6637
|
+
return product.pricing.modifier_group_ids.flatMap((groupId) => {
|
|
6638
|
+
const group = lookup.modifierGroupsById[groupId];
|
|
6639
|
+
return group ? [group] : [];
|
|
6640
|
+
});
|
|
6641
|
+
};
|
|
6642
|
+
const buildSlots = (lookup, groups) => {
|
|
6643
|
+
const slots = [];
|
|
6644
|
+
for (const group of groups) {
|
|
6645
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6646
|
+
for (const modifierId of group.modifier_ids) {
|
|
6647
|
+
if (seen.has(modifierId)) continue;
|
|
6648
|
+
seen.add(modifierId);
|
|
6649
|
+
const product = lookup.productsById[modifierId];
|
|
6650
|
+
const entry = product ? resolvePricingModifier(product, group.id) : null;
|
|
6651
|
+
const modifierMax = entry?.maximum_quantity ?? group.maximum_quantity;
|
|
6652
|
+
const row = {
|
|
6653
|
+
group,
|
|
6654
|
+
modifierId,
|
|
6655
|
+
modifierMin: entry?.minimum_quantity ?? 0
|
|
6656
|
+
};
|
|
6657
|
+
const counts = {
|
|
6658
|
+
modifierMax,
|
|
6659
|
+
instances: 0,
|
|
6660
|
+
next: 2
|
|
6661
|
+
};
|
|
6662
|
+
slots.push({
|
|
6663
|
+
...row,
|
|
6664
|
+
...counts
|
|
6665
|
+
});
|
|
6666
|
+
}
|
|
6667
|
+
}
|
|
6668
|
+
return slots;
|
|
6669
|
+
};
|
|
6670
|
+
const listingsFor = (lookup, groups, childId) => {
|
|
6671
|
+
const direct = [];
|
|
6672
|
+
for (const group of groups) {
|
|
6673
|
+
if (!group.modifier_ids.includes(childId)) continue;
|
|
6674
|
+
direct.push({
|
|
6675
|
+
group,
|
|
6676
|
+
modifierId: childId
|
|
6677
|
+
});
|
|
6678
|
+
}
|
|
6679
|
+
if (direct.length > 0) return direct;
|
|
6680
|
+
const child = lookup.productsById[childId];
|
|
6681
|
+
const viaBase = [];
|
|
6682
|
+
for (const entry of child?.pricing.variants ?? []) {
|
|
6683
|
+
const base = lookup.productsById[entry.product_id];
|
|
6684
|
+
if (!child || !base) continue;
|
|
6685
|
+
if (!selectionForVariant(lookup, base, child)) continue;
|
|
6686
|
+
for (const group of groups) {
|
|
6687
|
+
if (!group.modifier_ids.includes(base.id)) continue;
|
|
6688
|
+
viaBase.push({
|
|
6689
|
+
group,
|
|
6690
|
+
modifierId: base.id
|
|
6691
|
+
});
|
|
6692
|
+
}
|
|
6693
|
+
}
|
|
6694
|
+
return viaBase;
|
|
6695
|
+
};
|
|
6696
|
+
const slotKey = (groupId, modifierId) => {
|
|
6697
|
+
return `${groupId}/${modifierId}`;
|
|
6698
|
+
};
|
|
6699
|
+
/** Same product id more than once under one parent, at any depth. */
|
|
6700
|
+
const hasRepeatedProductSibling = (lines, rootUid) => {
|
|
6701
|
+
const kidsOf = /* @__PURE__ */ new Map();
|
|
6702
|
+
for (const line of lines) {
|
|
6703
|
+
if (line.parent_uid == null) continue;
|
|
6704
|
+
const list = kidsOf.get(line.parent_uid) ?? [];
|
|
6705
|
+
list.push(line);
|
|
6706
|
+
kidsOf.set(line.parent_uid, list);
|
|
6707
|
+
}
|
|
6708
|
+
const walk = (uid) => {
|
|
6709
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6710
|
+
for (const child of kidsOf.get(uid) ?? []) {
|
|
6711
|
+
if (seen.has(child.id)) return true;
|
|
6712
|
+
seen.add(child.id);
|
|
6713
|
+
if (walk(child.uid)) return true;
|
|
6714
|
+
}
|
|
6715
|
+
return false;
|
|
6716
|
+
};
|
|
6717
|
+
return walk(rootUid);
|
|
6718
|
+
};
|
|
6719
|
+
const noteRules = (state, broken) => {
|
|
6720
|
+
if (broken) state.rulesBroken = true;
|
|
6721
|
+
};
|
|
6722
|
+
/** Search steps per parent. Far above any real menu. A cart that needs more
|
|
6723
|
+
* is rejected: a partial search never accepts anything. */
|
|
6724
|
+
const SEARCH_BUDGET = 4096;
|
|
6725
|
+
const OWN_MONEY = [
|
|
6726
|
+
"unit_subtotal",
|
|
6727
|
+
"subtotal",
|
|
6728
|
+
"unit_discount",
|
|
6729
|
+
"discount_total",
|
|
6730
|
+
"unit_tax_total",
|
|
6731
|
+
"tax_total",
|
|
6732
|
+
"unit_total",
|
|
6733
|
+
"total",
|
|
6734
|
+
"discount_rate"
|
|
6735
|
+
];
|
|
6736
|
+
const emptyUsage = () => {
|
|
6737
|
+
return {
|
|
6738
|
+
bySlot: /* @__PURE__ */ new Map(),
|
|
6739
|
+
byGroup: /* @__PURE__ */ new Map()
|
|
6740
|
+
};
|
|
6741
|
+
};
|
|
6742
|
+
const addUsage = (usage, slot, quantity) => {
|
|
6743
|
+
const own = usage.bySlot.get(slot) ?? 0;
|
|
6744
|
+
usage.bySlot.set(slot, own + quantity);
|
|
6745
|
+
const group = usage.byGroup.get(slot.group.id) ?? 0;
|
|
6746
|
+
usage.byGroup.set(slot.group.id, group + quantity);
|
|
6747
|
+
};
|
|
6748
|
+
const hasRoom = (usage, slot, quantity) => {
|
|
6749
|
+
const own = usage.bySlot.get(slot) ?? 0;
|
|
6750
|
+
if ((usage.byGroup.get(slot.group.id) ?? 0) + quantity > slot.group.maximum_quantity) return false;
|
|
6751
|
+
return own + quantity <= slot.modifierMax;
|
|
6752
|
+
};
|
|
6753
|
+
/** Every instance counts: usage is the sum over all sibling lines. A slot's
|
|
6754
|
+
* own minimum binds once the slot is used, as in the customization session.
|
|
6755
|
+
* An unused slot owes nothing; the group minimum is what requires a pick. */
|
|
6756
|
+
const limitsBroken = (groups, slots, usage) => {
|
|
6757
|
+
for (const group of groups) {
|
|
6758
|
+
const used = usage.byGroup.get(group.id) ?? 0;
|
|
6759
|
+
if (used > group.maximum_quantity) return true;
|
|
6760
|
+
if (used < group.minimum_quantity) return true;
|
|
6761
|
+
}
|
|
6762
|
+
for (const slot of slots) {
|
|
6763
|
+
const used = usage.bySlot.get(slot) ?? 0;
|
|
6764
|
+
if (used > slot.modifierMax) return true;
|
|
6765
|
+
if (used > 0 && used < slot.modifierMin) return true;
|
|
6766
|
+
}
|
|
6767
|
+
return false;
|
|
6768
|
+
};
|
|
6769
|
+
const claimKey = (line) => {
|
|
6770
|
+
const subtotal = new Big(line.unit_subtotal).toString();
|
|
6771
|
+
if (line.tax_rate == null) return `${subtotal}|null`;
|
|
6772
|
+
return `${subtotal}|${new Big(line.tax_rate).toString()}`;
|
|
6773
|
+
};
|
|
6774
|
+
const sameOwnMoney = (line, money) => {
|
|
6775
|
+
for (const field of OWN_MONEY) if (!new Big(line[field]).eq(money[field])) return false;
|
|
6776
|
+
if (line.tax_rate == null) return false;
|
|
6777
|
+
return new Big(line.tax_rate).eq(money.tax_rate);
|
|
6778
|
+
};
|
|
6779
|
+
/** True when the line's own money is exactly what the menu yields for this
|
|
6780
|
+
* slot: the slot's price and tax, with 0 to `quantity` charged units
|
|
6781
|
+
* (charge_above shares free units between siblings). It only narrows which
|
|
6782
|
+
* slots a line may take. Nothing from the cart is ever copied into the
|
|
6783
|
+
* expected tree: the final comparison is still exact. */
|
|
6784
|
+
const slotYieldsLine = (lookup, line, ordered, slot) => {
|
|
6785
|
+
const quantity = line.quantity;
|
|
6786
|
+
if (quantity <= 0) return false;
|
|
6787
|
+
if (quantity > slot.modifierMax) return false;
|
|
6788
|
+
if (quantity > slot.group.maximum_quantity) return false;
|
|
6789
|
+
const listed = lookup.productsById[slot.modifierId] ?? ordered;
|
|
6790
|
+
const pricing = modifierUnitPricing(listed, ordered, slot.group.id);
|
|
6791
|
+
const free = (resolvePricingModifier(listed, slot.group.id)?.charge_above_quantity ?? 0) + (slot.group.charge_above_quantity ?? 0);
|
|
6792
|
+
const unit = new Big(pricing.price);
|
|
6793
|
+
const config = {
|
|
6794
|
+
quantity,
|
|
6795
|
+
taxRate: pricing.taxRate
|
|
6796
|
+
};
|
|
6797
|
+
const inclusive = { taxInclusive: pricing.isTaxInclusive };
|
|
6798
|
+
const fewest = Math.max(0, quantity - free);
|
|
6799
|
+
for (let charged = fewest; charged <= quantity; charged++) {
|
|
6800
|
+
const unitSubtotal = unit.times(charged).div(quantity);
|
|
6801
|
+
const money = applyUnitMoney({
|
|
6802
|
+
...config,
|
|
6803
|
+
...inclusive,
|
|
6804
|
+
unitSubtotal
|
|
6805
|
+
});
|
|
6806
|
+
if (sameOwnMoney(line, money)) return true;
|
|
6807
|
+
}
|
|
6808
|
+
return false;
|
|
6809
|
+
};
|
|
6810
|
+
/** Slots the line may take: those whose menu price it matches, else every
|
|
6811
|
+
* listed slot (the comparison then reports the price it cannot match). */
|
|
6812
|
+
const optionsFor = (lookup, line, ordered, listed) => {
|
|
6813
|
+
if (listed.length < 2) return listed;
|
|
6814
|
+
const yielding = listed.filter((slot) => {
|
|
6815
|
+
return slotYieldsLine(lookup, line, ordered, slot);
|
|
6816
|
+
});
|
|
6817
|
+
if (yielding.length > 0) return yielding;
|
|
6818
|
+
return listed;
|
|
6819
|
+
};
|
|
6820
|
+
/** Depth-first assignment of sibling lines to slots, in a fixed order, within
|
|
6821
|
+
* capacity, until every group and modifier limit holds. Twins (same shape and
|
|
6822
|
+
* same claimed price) are interchangeable, so they are forced into
|
|
6823
|
+
* non-decreasing option order. At most SEARCH_BUDGET steps of O(slots). */
|
|
6824
|
+
const searchAssignment = (plans, groups, slots) => {
|
|
6825
|
+
const total = plans.length;
|
|
6826
|
+
const usage = emptyUsage();
|
|
6827
|
+
const chosen = [];
|
|
6828
|
+
const picked = [];
|
|
6829
|
+
const cursor = [0];
|
|
6830
|
+
let depth = 0;
|
|
6831
|
+
let steps = 0;
|
|
6832
|
+
while (depth >= 0) {
|
|
6833
|
+
steps += 1;
|
|
6834
|
+
if (steps > SEARCH_BUDGET) return null;
|
|
6835
|
+
if (depth === total) {
|
|
6836
|
+
if (!limitsBroken(groups, slots, usage)) return chosen;
|
|
6837
|
+
depth -= 1;
|
|
6838
|
+
addUsage(usage, chosen[depth], -plans[depth].line.quantity);
|
|
6839
|
+
continue;
|
|
6840
|
+
}
|
|
6841
|
+
const plan = plans[depth];
|
|
6842
|
+
const quantity = plan.line.quantity;
|
|
6843
|
+
let found = -1;
|
|
6844
|
+
for (let next = cursor[depth]; next < plan.options.length; next++) {
|
|
6845
|
+
if (!hasRoom(usage, plan.options[next], quantity)) continue;
|
|
6846
|
+
found = next;
|
|
6847
|
+
break;
|
|
6848
|
+
}
|
|
6849
|
+
if (found < 0) {
|
|
6850
|
+
depth -= 1;
|
|
6851
|
+
if (depth < 0) break;
|
|
6852
|
+
addUsage(usage, chosen[depth], -plans[depth].line.quantity);
|
|
6853
|
+
continue;
|
|
6854
|
+
}
|
|
6855
|
+
cursor[depth] = found + 1;
|
|
6856
|
+
picked[depth] = found;
|
|
6857
|
+
chosen[depth] = plan.options[found];
|
|
6858
|
+
addUsage(usage, chosen[depth], quantity);
|
|
6859
|
+
depth += 1;
|
|
6860
|
+
cursor[depth] = plans[depth]?.twin === true ? picked[depth - 1] : 0;
|
|
6861
|
+
}
|
|
6862
|
+
return null;
|
|
6863
|
+
};
|
|
6864
|
+
/** Used when no assignment satisfies every limit: first slot with room, so the
|
|
6865
|
+
* repaired tree and the broken-rules flag still come out deterministic. */
|
|
6866
|
+
const greedyAssignment = (plans) => {
|
|
6867
|
+
const usage = emptyUsage();
|
|
6868
|
+
const chosen = [];
|
|
6869
|
+
let overCapacity = false;
|
|
6870
|
+
for (const plan of plans) {
|
|
6871
|
+
const quantity = plan.line.quantity;
|
|
6872
|
+
const fits = (slot) => hasRoom(usage, slot, quantity);
|
|
6873
|
+
let slot = plan.options.find(fits) ?? plan.listed.find(fits);
|
|
6874
|
+
if (!slot) {
|
|
6875
|
+
slot = plan.listed[0];
|
|
6876
|
+
overCapacity = true;
|
|
6877
|
+
}
|
|
6878
|
+
addUsage(usage, slot, quantity);
|
|
6879
|
+
chosen.push(slot);
|
|
6880
|
+
}
|
|
6881
|
+
return {
|
|
6882
|
+
chosen,
|
|
6883
|
+
overCapacity
|
|
6884
|
+
};
|
|
6885
|
+
};
|
|
6886
|
+
const planKids = (lookup, groups, byKey, kids, prefix, state) => {
|
|
6887
|
+
const plans = [];
|
|
6888
|
+
let previous = "";
|
|
6889
|
+
for (const child of kids) {
|
|
6890
|
+
if (pathVisits(prefix, child.id)) continue;
|
|
6891
|
+
const ordered = lookup.productsById[child.id];
|
|
6892
|
+
const listings = listingsFor(lookup, groups, child.id);
|
|
6893
|
+
if (!ordered || listings.length === 0) continue;
|
|
6894
|
+
const listed = [];
|
|
6895
|
+
for (const item of listings) {
|
|
6896
|
+
const slot = byKey.get(slotKey(item.group.id, item.modifierId));
|
|
6897
|
+
if (slot) listed.push(slot);
|
|
6898
|
+
}
|
|
6899
|
+
if (listed.length === 0) continue;
|
|
6900
|
+
const signature = `${selectionKey(child, state.childrenByParent, state.keyCache)}|${claimKey(child)}`;
|
|
6901
|
+
const options = optionsFor(lookup, child, ordered, listed);
|
|
6902
|
+
const twin = signature === previous;
|
|
6903
|
+
plans.push({
|
|
6904
|
+
line: child,
|
|
6905
|
+
ordered,
|
|
6906
|
+
listed,
|
|
6907
|
+
options,
|
|
6908
|
+
twin
|
|
6909
|
+
});
|
|
6910
|
+
previous = signature;
|
|
6911
|
+
}
|
|
6912
|
+
return plans;
|
|
6913
|
+
};
|
|
6914
|
+
const placeChildren = (lookup, parent, parentLine, prefix, state) => {
|
|
6915
|
+
const groups = parentGroups(lookup, parent);
|
|
6916
|
+
const slots = buildSlots(lookup, groups);
|
|
6917
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
6918
|
+
for (const slot of slots) byKey.set(slotKey(slot.group.id, slot.modifierId), slot);
|
|
6919
|
+
const kids = (state.childrenByParent.get(parentLine.uid) ?? []).slice();
|
|
6920
|
+
kids.sort((left, right) => {
|
|
6921
|
+
const a = selectionKey(left, state.childrenByParent, state.keyCache);
|
|
6922
|
+
const b = selectionKey(right, state.childrenByParent, state.keyCache);
|
|
6923
|
+
const byShape = a.localeCompare(b);
|
|
6924
|
+
if (byShape !== 0) return byShape;
|
|
6925
|
+
return claimKey(left).localeCompare(claimKey(right));
|
|
6926
|
+
});
|
|
6927
|
+
const plans = planKids(lookup, groups, byKey, kids, prefix, state);
|
|
6928
|
+
let assignment = [];
|
|
6929
|
+
if (plans.length > 0) assignment = searchAssignment(plans, groups, slots);
|
|
6930
|
+
if (!assignment) {
|
|
6931
|
+
const fallback = greedyAssignment(plans);
|
|
6932
|
+
assignment = fallback.chosen;
|
|
6933
|
+
if (fallback.overCapacity) state.overCapacity = true;
|
|
6934
|
+
}
|
|
6935
|
+
const usage = emptyUsage();
|
|
6936
|
+
const placed = [];
|
|
6937
|
+
for (let index = 0; index < plans.length; index++) {
|
|
6938
|
+
const plan = plans[index];
|
|
6939
|
+
const chosen = assignment[index];
|
|
6940
|
+
const child = plan.line;
|
|
6941
|
+
const ordered = plan.ordered;
|
|
6942
|
+
const base = `${`${prefix}/group/${chosen.group.id}`}/modifier/${chosen.modifierId}`;
|
|
6943
|
+
let path = base;
|
|
6944
|
+
if (chosen.instances > 0) {
|
|
6945
|
+
path = `${base}/instance/${chosen.next}`;
|
|
6946
|
+
chosen.next += 1;
|
|
6947
|
+
}
|
|
6948
|
+
chosen.instances += 1;
|
|
6949
|
+
addUsage(usage, chosen, child.quantity);
|
|
6950
|
+
state.paths[path] = child.quantity;
|
|
6951
|
+
const listed = lookup.productsById[chosen.modifierId] ?? ordered;
|
|
6952
|
+
const pricing = modifierUnitPricing(listed, ordered, chosen.group.id);
|
|
6953
|
+
const n1 = {
|
|
6954
|
+
product: ordered,
|
|
6955
|
+
quantity: child.quantity,
|
|
6956
|
+
path
|
|
6957
|
+
};
|
|
6958
|
+
const n2 = {
|
|
6959
|
+
children: [],
|
|
6960
|
+
listed,
|
|
6961
|
+
group: chosen.group
|
|
6962
|
+
};
|
|
6963
|
+
const n3 = {
|
|
6964
|
+
unitPrice: new Big(0),
|
|
6965
|
+
taxRate: pricing.taxRate
|
|
6966
|
+
};
|
|
6967
|
+
const n4 = { taxInclusive: pricing.isTaxInclusive };
|
|
6968
|
+
const node = {
|
|
6969
|
+
...n1,
|
|
6970
|
+
...n2,
|
|
6971
|
+
...n3,
|
|
6972
|
+
...n4
|
|
6973
|
+
};
|
|
6974
|
+
node.children = placeChildren(lookup, ordered, child, path, state);
|
|
6975
|
+
placed.push(node);
|
|
6976
|
+
}
|
|
6977
|
+
noteRules(state, limitsBroken(groups, slots, usage));
|
|
6978
|
+
billGroups(placed);
|
|
6979
|
+
return placed;
|
|
6980
|
+
};
|
|
6981
|
+
const billGroups = (nodes) => {
|
|
6982
|
+
const byGroup = /* @__PURE__ */ new Map();
|
|
6983
|
+
for (const node of nodes) {
|
|
6984
|
+
if (!node.group) continue;
|
|
6985
|
+
const list = byGroup.get(node.group.id) ?? [];
|
|
6986
|
+
list.push(node);
|
|
6987
|
+
byGroup.set(node.group.id, list);
|
|
6988
|
+
}
|
|
6989
|
+
for (const list of byGroup.values()) {
|
|
6990
|
+
const group = list[0]?.group;
|
|
6991
|
+
if (group) billGroup(group, list);
|
|
6992
|
+
}
|
|
6993
|
+
};
|
|
6994
|
+
/** Menu order, then instance order. One modifier's free units are shared,
|
|
6995
|
+
* so a second sibling line is not free just because it is its own line. */
|
|
6996
|
+
const billGroup = (group, nodes) => {
|
|
6997
|
+
const groupChargeAbove = group.charge_above_quantity ?? 0;
|
|
6998
|
+
let chargedCount = 0;
|
|
6999
|
+
const consumed = /* @__PURE__ */ new Map();
|
|
7000
|
+
const ordered = [];
|
|
7001
|
+
for (const modifierId of group.modifier_ids) for (const node of nodes) if (node.listed?.id === modifierId) ordered.push(node);
|
|
7002
|
+
for (const node of ordered) {
|
|
7003
|
+
if (node.quantity <= 0 || !node.listed || !node.group) continue;
|
|
7004
|
+
const entry = resolvePricingModifier(node.listed, node.group.id);
|
|
7005
|
+
const unit = new Big(modifierUnitPricing(...[
|
|
7006
|
+
node.listed,
|
|
7007
|
+
node.product,
|
|
7008
|
+
node.group.id
|
|
7009
|
+
]).price);
|
|
7010
|
+
const allowance0 = entry?.charge_above_quantity ?? 0;
|
|
7011
|
+
const id = node.listed.id;
|
|
7012
|
+
const taken = consumed.get(id) ?? 0;
|
|
7013
|
+
const allowance = Math.max(0, allowance0 - taken);
|
|
7014
|
+
let remaining = Math.max(0, node.quantity - allowance);
|
|
7015
|
+
consumed.set(id, taken + node.quantity);
|
|
7016
|
+
let linePrice = new Big(0);
|
|
7017
|
+
while (remaining > 0) {
|
|
7018
|
+
if (chargedCount >= groupChargeAbove) linePrice = linePrice.plus(unit);
|
|
7019
|
+
chargedCount += 1;
|
|
7020
|
+
remaining -= 1;
|
|
7021
|
+
}
|
|
7022
|
+
node.unitPrice = node.quantity > 0 ? linePrice.div(node.quantity) : unit;
|
|
7023
|
+
}
|
|
7024
|
+
};
|
|
7025
|
+
const applyUnitMoney = (config) => {
|
|
7026
|
+
const qty = new Big(config.quantity);
|
|
7027
|
+
const net = config.unitSubtotal;
|
|
7028
|
+
let unitTax = net.times(config.taxRate);
|
|
7029
|
+
let unitTotal = net.plus(unitTax);
|
|
7030
|
+
if (config.taxInclusive) {
|
|
7031
|
+
const divisor = new Big(1).plus(config.taxRate);
|
|
7032
|
+
unitTax = net.times(config.taxRate).div(divisor);
|
|
7033
|
+
unitTotal = net;
|
|
7034
|
+
}
|
|
7035
|
+
const r1 = {
|
|
7036
|
+
tax_rate: config.taxRate,
|
|
7037
|
+
discount_rate: 0
|
|
7038
|
+
};
|
|
7039
|
+
const r2 = { unit_subtotal: config.unitSubtotal.toNumber() };
|
|
7040
|
+
const r3 = {
|
|
7041
|
+
unit_discount: 0,
|
|
7042
|
+
unit_tax_total: unitTax.toNumber()
|
|
7043
|
+
};
|
|
7044
|
+
const r4 = { unit_total: unitTotal.toNumber() };
|
|
7045
|
+
const r5 = { subtotal: config.unitSubtotal.times(qty).toNumber() };
|
|
7046
|
+
const r6 = {
|
|
7047
|
+
discount_total: 0,
|
|
7048
|
+
tax_total: unitTax.times(qty).toNumber()
|
|
7049
|
+
};
|
|
7050
|
+
const r7 = { total: unitTotal.times(qty).toNumber() };
|
|
7051
|
+
return {
|
|
7052
|
+
...r1,
|
|
7053
|
+
...r2,
|
|
7054
|
+
...r3,
|
|
7055
|
+
...r4,
|
|
7056
|
+
...r5,
|
|
7057
|
+
...r6,
|
|
7058
|
+
...r7
|
|
7059
|
+
};
|
|
7060
|
+
};
|
|
7061
|
+
const emptyGroup = () => {
|
|
7062
|
+
const g1 = {
|
|
7063
|
+
unit_group_subtotal: 0,
|
|
7064
|
+
unit_group_discount: 0
|
|
7065
|
+
};
|
|
7066
|
+
const g2 = {
|
|
7067
|
+
unit_group_tax_total: 0,
|
|
7068
|
+
unit_group_total: 0
|
|
7069
|
+
};
|
|
7070
|
+
const g3 = {
|
|
7071
|
+
group_subtotal: 0,
|
|
7072
|
+
group_discount: 0
|
|
7073
|
+
};
|
|
7074
|
+
const g4 = {
|
|
7075
|
+
group_tax_total: 0,
|
|
7076
|
+
group_total: 0
|
|
7077
|
+
};
|
|
7078
|
+
return {
|
|
7079
|
+
...g1,
|
|
7080
|
+
...g2,
|
|
7081
|
+
...g3,
|
|
7082
|
+
...g4
|
|
7083
|
+
};
|
|
7084
|
+
};
|
|
7085
|
+
const displayName = (text, locale) => {
|
|
7086
|
+
if (text[locale]) return text[locale];
|
|
7087
|
+
return Object.values(text)[0] ?? "";
|
|
7088
|
+
};
|
|
7089
|
+
const emit = (node, parentUid, parentId, locale, out) => {
|
|
7090
|
+
const mIn1 = {
|
|
7091
|
+
unitSubtotal: node.unitPrice,
|
|
7092
|
+
quantity: node.quantity
|
|
7093
|
+
};
|
|
7094
|
+
const mIn2 = {
|
|
7095
|
+
taxRate: node.taxRate,
|
|
7096
|
+
taxInclusive: node.taxInclusive
|
|
7097
|
+
};
|
|
7098
|
+
const money = applyUnitMoney({
|
|
7099
|
+
...mIn1,
|
|
7100
|
+
...mIn2
|
|
7101
|
+
});
|
|
7102
|
+
const imageUrl = node.product.image_url ?? void 0;
|
|
7103
|
+
const imagePatch = imageUrl ? { image_url: imageUrl } : {};
|
|
7104
|
+
const catalog = {
|
|
7105
|
+
metadata: { ...node.product.metadata ?? {} },
|
|
7106
|
+
bundled_items: [...node.product.bundled_items ?? []]
|
|
7107
|
+
};
|
|
7108
|
+
const r1 = {
|
|
7109
|
+
type: "PRODUCT",
|
|
7110
|
+
uid: randomUUID()
|
|
7111
|
+
};
|
|
7112
|
+
const r2 = {
|
|
7113
|
+
id: node.product.id,
|
|
7114
|
+
parent_uid: parentUid,
|
|
7115
|
+
parent_id: parentId
|
|
7116
|
+
};
|
|
7117
|
+
const r3 = {
|
|
7118
|
+
name: displayName(node.product.name, locale),
|
|
7119
|
+
quantity: node.quantity
|
|
7120
|
+
};
|
|
7121
|
+
const r4 = {
|
|
7122
|
+
redeem: [],
|
|
7123
|
+
...imagePatch,
|
|
7124
|
+
...catalog,
|
|
7125
|
+
...money
|
|
7126
|
+
};
|
|
7127
|
+
const line = {
|
|
7128
|
+
...r1,
|
|
7129
|
+
...r2,
|
|
7130
|
+
...r3,
|
|
7131
|
+
...r4,
|
|
7132
|
+
...emptyGroup()
|
|
7133
|
+
};
|
|
7134
|
+
out.push(line);
|
|
7135
|
+
for (const child of node.children) emit(child, line.uid, line.id, locale, out);
|
|
7136
|
+
let sub = new Big(line.subtotal);
|
|
7137
|
+
let tax = new Big(line.tax_total);
|
|
7138
|
+
let tot = new Big(line.total);
|
|
7139
|
+
let childSub = new Big(0);
|
|
7140
|
+
let childTax = new Big(0);
|
|
7141
|
+
let childTot = new Big(0);
|
|
7142
|
+
for (const child of out) {
|
|
7143
|
+
if (child.parent_uid !== line.uid) continue;
|
|
7144
|
+
childSub = childSub.plus(child.group_subtotal);
|
|
7145
|
+
childTax = childTax.plus(child.group_tax_total);
|
|
7146
|
+
childTot = childTot.plus(child.group_total);
|
|
7147
|
+
}
|
|
7148
|
+
const qty = new Big(line.quantity);
|
|
7149
|
+
sub = sub.plus(childSub.times(qty));
|
|
7150
|
+
tax = tax.plus(childTax.times(qty));
|
|
7151
|
+
tot = tot.plus(childTot.times(qty));
|
|
7152
|
+
line.group_subtotal = sub.toNumber();
|
|
7153
|
+
line.group_tax_total = tax.toNumber();
|
|
7154
|
+
line.group_total = tot.toNumber();
|
|
7155
|
+
line.group_discount = 0;
|
|
7156
|
+
line.unit_group_subtotal = sub.div(qty).toNumber();
|
|
7157
|
+
line.unit_group_tax_total = tax.div(qty).toNumber();
|
|
7158
|
+
line.unit_group_total = tot.div(qty).toNumber();
|
|
7159
|
+
line.unit_group_discount = 0;
|
|
7160
|
+
};
|
|
7161
|
+
const rootNode = (lookup, root, state) => {
|
|
7162
|
+
const product = lookup.productsById[root.id];
|
|
7163
|
+
if (!product) return null;
|
|
7164
|
+
const max = product.pricing.maximum_quantity;
|
|
7165
|
+
const min = product.pricing.minimum_quantity;
|
|
7166
|
+
noteRules(state, root.quantity > max || root.quantity < min);
|
|
7167
|
+
const path = `/${product.id}`;
|
|
7168
|
+
state.paths[path] = root.quantity;
|
|
7169
|
+
const children = placeChildren(lookup, product, root, path, state);
|
|
7170
|
+
const r1 = {
|
|
7171
|
+
product,
|
|
7172
|
+
quantity: root.quantity,
|
|
7173
|
+
path,
|
|
7174
|
+
children
|
|
7175
|
+
};
|
|
7176
|
+
const r2 = {
|
|
7177
|
+
listed: null,
|
|
7178
|
+
group: null
|
|
7179
|
+
};
|
|
7180
|
+
const r3 = { unitPrice: new Big(product.pricing.price) };
|
|
7181
|
+
const r4 = { taxRate: product.pricing.tax_rate };
|
|
7182
|
+
const r5 = { taxInclusive: product.pricing.is_tax_inclusive };
|
|
7183
|
+
return {
|
|
7184
|
+
...r1,
|
|
7185
|
+
...r2,
|
|
7186
|
+
...r3,
|
|
7187
|
+
...r4,
|
|
7188
|
+
...r5
|
|
7189
|
+
};
|
|
7190
|
+
};
|
|
7191
|
+
/** Place cart lines on menu slots and price them from the menu.
|
|
7192
|
+
* The tree is built from product id, parent, and quantity. A line's claimed
|
|
7193
|
+
* own price is read for one purpose: to rank which listed slots it may take
|
|
7194
|
+
* (see slotYieldsLine). The expected money is always the menu's for the
|
|
7195
|
+
* chosen slot, so a claim the menu cannot yield stays a mismatch.
|
|
7196
|
+
* A quantity above 1 stays one line. Extra sibling lines reuse a slot
|
|
7197
|
+
* only while its max still has room, at `/instance/2`, `/instance/3`, ….
|
|
7198
|
+
* Indices are assigned here. A forged index in the cart is not an input. */
|
|
7199
|
+
const placeCartLines = (root, lines, lookup, locale) => {
|
|
7200
|
+
const childrenByParent = /* @__PURE__ */ new Map();
|
|
7201
|
+
for (const line of lines) {
|
|
7202
|
+
if (line.parent_uid == null) continue;
|
|
7203
|
+
const list = childrenByParent.get(line.parent_uid) ?? [];
|
|
7204
|
+
list.push(line);
|
|
7205
|
+
childrenByParent.set(line.parent_uid, list);
|
|
7206
|
+
}
|
|
7207
|
+
const state = {
|
|
7208
|
+
paths: {},
|
|
7209
|
+
overCapacity: false,
|
|
7210
|
+
rulesBroken: false,
|
|
7211
|
+
childrenByParent,
|
|
7212
|
+
keyCache: /* @__PURE__ */ new Map()
|
|
7213
|
+
};
|
|
7214
|
+
const node = rootNode(lookup, root, state);
|
|
7215
|
+
const out = [];
|
|
7216
|
+
if (node) emit(node, null, null, locale, out);
|
|
7217
|
+
if (state.overCapacity) state.rulesBroken = true;
|
|
7218
|
+
return {
|
|
7219
|
+
paths: state.paths,
|
|
7220
|
+
overCapacity: state.overCapacity,
|
|
7221
|
+
rulesBroken: state.rulesBroken,
|
|
7222
|
+
lines: out
|
|
7223
|
+
};
|
|
7224
|
+
};
|
|
7225
|
+
//#endregion
|
|
6606
7226
|
//#region src/order/validate.ts
|
|
6607
7227
|
const PRICE_FIELDS = [
|
|
6608
7228
|
"unit_subtotal",
|
|
@@ -6661,11 +7281,34 @@ const rateStr = (n) => {
|
|
|
6661
7281
|
if (n == null) return "null";
|
|
6662
7282
|
return bigStr(n);
|
|
6663
7283
|
};
|
|
7284
|
+
const structureKey = (lines, line) => {
|
|
7285
|
+
const nested = lines.filter((child) => child.parent_uid === line.uid).map((child) => structureKey(lines, child));
|
|
7286
|
+
nested.sort((left, right) => left.localeCompare(right));
|
|
7287
|
+
return `${line.id}x${line.quantity}(${nested.join(",")})`;
|
|
7288
|
+
};
|
|
7289
|
+
/** Sorted by id as before. Only same-id siblings are told apart further:
|
|
7290
|
+
* by nested shape, then unit price, so each pairs with its own expectation. */
|
|
6664
7291
|
const childrenOf = (lines, parentUid) => {
|
|
6665
7292
|
const children = lines.filter((line) => {
|
|
6666
7293
|
return line.parent_uid === parentUid;
|
|
6667
7294
|
});
|
|
6668
|
-
|
|
7295
|
+
const shapes = /* @__PURE__ */ new Map();
|
|
7296
|
+
const shapeOf = (line) => {
|
|
7297
|
+
const cached = shapes.get(line.uid);
|
|
7298
|
+
if (cached !== void 0) return cached;
|
|
7299
|
+
const shape = structureKey(lines, line);
|
|
7300
|
+
shapes.set(line.uid, shape);
|
|
7301
|
+
return shape;
|
|
7302
|
+
};
|
|
7303
|
+
children.sort((a, b) => {
|
|
7304
|
+
const byId = a.id.localeCompare(b.id);
|
|
7305
|
+
if (byId !== 0) return byId;
|
|
7306
|
+
const byShape = shapeOf(a).localeCompare(shapeOf(b));
|
|
7307
|
+
if (byShape !== 0) return byShape;
|
|
7308
|
+
const bySubtotal = new Big(a.unit_subtotal).cmp(b.unit_subtotal);
|
|
7309
|
+
if (bySubtotal !== 0) return bySubtotal;
|
|
7310
|
+
return new Big(a.unit_tax_total).cmp(b.unit_tax_total);
|
|
7311
|
+
});
|
|
6669
7312
|
return children;
|
|
6670
7313
|
};
|
|
6671
7314
|
const collectUnmatchedModifiers = (lines, lookup, parentProductId, parentUid, out) => {
|
|
@@ -6908,10 +7551,17 @@ const validateProductRoot = (root, lines, lookup, locale, out) => {
|
|
|
6908
7551
|
if (status === "OUT_OF_STOCK") pushLineIssue(out, root.uid, { code: "OUT_OF_STOCK" });
|
|
6909
7552
|
if (!isOnSchedule(product.availability.schedules)) pushLineIssue(out, root.uid, { code: "OUTSIDE_HOURS" });
|
|
6910
7553
|
collectUnmatchedModifiers(lines, lookup, root.id, root.uid, out);
|
|
6911
|
-
|
|
6912
|
-
|
|
6913
|
-
|
|
6914
|
-
|
|
7554
|
+
let expected;
|
|
7555
|
+
if (hasRepeatedProductSibling(lines, root.uid)) {
|
|
7556
|
+
const placed = placeCartLines(root, lines, lookup, locale);
|
|
7557
|
+
if (placed.rulesBroken) pushLineIssue(out, root.uid, { code: "MODIFIER_RULES_INVALID" });
|
|
7558
|
+
expected = placed.lines;
|
|
7559
|
+
} else {
|
|
7560
|
+
const snapshot = fromOrderLines(root, lines, lookup);
|
|
7561
|
+
const session = createSession(lookup, root.id, { snapshot });
|
|
7562
|
+
if (session.validate() !== "VALID") pushLineIssue(out, root.uid, { code: "MODIFIER_RULES_INVALID" });
|
|
7563
|
+
expected = toOrderLines(session.root, locale);
|
|
7564
|
+
}
|
|
6915
7565
|
const expectedRoot = expected.find((line) => line.parent_uid == null);
|
|
6916
7566
|
if (!expectedRoot) {
|
|
6917
7567
|
pushLineIssue(out, root.uid, { code: "STRUCTURE_MISMATCH" });
|
|
@@ -7234,6 +7884,17 @@ const collectOrderLineDiffs = (input) => {
|
|
|
7234
7884
|
continue;
|
|
7235
7885
|
}
|
|
7236
7886
|
try {
|
|
7887
|
+
if (hasRepeatedProductSibling(input.lines, root.uid)) {
|
|
7888
|
+
const placed = placeCartLines(root, input.lines, lookup, locale);
|
|
7889
|
+
const next = placed.rulesBroken ? null : placed.lines;
|
|
7890
|
+
diffs.push({
|
|
7891
|
+
uid: root.uid,
|
|
7892
|
+
previous,
|
|
7893
|
+
next,
|
|
7894
|
+
issues
|
|
7895
|
+
});
|
|
7896
|
+
continue;
|
|
7897
|
+
}
|
|
7237
7898
|
const snapshot = fromOrderLines(root, input.lines, lookup);
|
|
7238
7899
|
const next = toOrderLines(createSession(lookup, root.id, { snapshot }).root, locale);
|
|
7239
7900
|
diffs.push({
|
|
@@ -7800,16 +8461,6 @@ const countCartProducts = (lines, includeModifiers) => {
|
|
|
7800
8461
|
return line.type === "PRODUCT" || line.type == null;
|
|
7801
8462
|
}).reduce((sum, line) => sum + line.quantity, 0);
|
|
7802
8463
|
};
|
|
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
8464
|
//#endregion
|
|
7814
8465
|
//#region src/rules/absoluteDateMatcher.ts
|
|
7815
8466
|
var import_dayjs_min = /* @__PURE__ */ __toESM(require_dayjs_min(), 1);
|
|
@@ -8414,6 +9065,72 @@ const matchRelativeDateOperator = (condition, matcher, referenceDate) => {
|
|
|
8414
9065
|
}
|
|
8415
9066
|
};
|
|
8416
9067
|
//#endregion
|
|
9068
|
+
//#region src/rules/tagMatcher.ts
|
|
9069
|
+
const NEGATED_TO_POSITIVE = {
|
|
9070
|
+
"not equal to": "equal to",
|
|
9071
|
+
"is not": "is"
|
|
9072
|
+
};
|
|
9073
|
+
const SET_OPERATORS = [
|
|
9074
|
+
"is any of",
|
|
9075
|
+
"is all of",
|
|
9076
|
+
"is none of"
|
|
9077
|
+
];
|
|
9078
|
+
const isComparable = (kind, value) => {
|
|
9079
|
+
if (kind === "string") return typeof value === "string";
|
|
9080
|
+
if (kind === "boolean") return typeof value === "boolean";
|
|
9081
|
+
return typeof value === "number";
|
|
9082
|
+
};
|
|
9083
|
+
const normalize = (value, caseInsensitive) => {
|
|
9084
|
+
if (typeof value === "string" && caseInsensitive) return value.toLowerCase();
|
|
9085
|
+
return value;
|
|
9086
|
+
};
|
|
9087
|
+
const matchSet = (condition, kind, elements) => {
|
|
9088
|
+
const { operator, value } = condition;
|
|
9089
|
+
if (!Array.isArray(value)) throw new Error("Invalid tag set value");
|
|
9090
|
+
const caseInsensitive = getCaseInsensitive(condition);
|
|
9091
|
+
const wanted = new Set(value.map((item) => normalize(item, caseInsensitive)));
|
|
9092
|
+
const present = new Set(elements.map((item) => normalize(item, caseInsensitive)));
|
|
9093
|
+
const hasAny = elements.length > 0;
|
|
9094
|
+
switch (operator) {
|
|
9095
|
+
case "is any of": return [...present].some((item) => wanted.has(item));
|
|
9096
|
+
case "is all of": return [...wanted].every((item) => present.has(item));
|
|
9097
|
+
case "is none of": return !hasAny || ![...present].some((item) => wanted.has(item));
|
|
9098
|
+
default: throw new Error(`Operator ${operator} not supported in ${kind} tags`);
|
|
9099
|
+
}
|
|
9100
|
+
};
|
|
9101
|
+
/**
|
|
9102
|
+
* Evaluates one line's tag value against a tag condition.
|
|
9103
|
+
*
|
|
9104
|
+
* - Scalars use the primitive's matcher unchanged. A value of another type
|
|
9105
|
+
* never matches.
|
|
9106
|
+
* - Lists use "any element matches" for positive operators and "no element
|
|
9107
|
+
* matches the positive form" for negated ones. An empty list matches
|
|
9108
|
+
* negated operators and set operator "is none of", never the others.
|
|
9109
|
+
* - Set operators treat a scalar as a one-element set.
|
|
9110
|
+
*/
|
|
9111
|
+
const matchTagLine = (condition, kind, raw, matchScalar) => {
|
|
9112
|
+
const { operator } = condition;
|
|
9113
|
+
const isList = Array.isArray(raw);
|
|
9114
|
+
const items = isList ? raw : [raw];
|
|
9115
|
+
const comparable = items.filter((item) => isComparable(kind, item));
|
|
9116
|
+
if (SET_OPERATORS.includes(operator)) {
|
|
9117
|
+
if (kind === "boolean" || kind === "currency") return false;
|
|
9118
|
+
if (items.length > 0 && comparable.length === 0) return false;
|
|
9119
|
+
return matchSet(condition, kind, comparable);
|
|
9120
|
+
}
|
|
9121
|
+
const match = matchScalar;
|
|
9122
|
+
if (!isList) return comparable.length > 0 ? match(condition, raw) : false;
|
|
9123
|
+
const positive = NEGATED_TO_POSITIVE[operator];
|
|
9124
|
+
if (items.length === 0) return typeof positive === "string";
|
|
9125
|
+
if (comparable.length === 0) return false;
|
|
9126
|
+
if (typeof positive === "undefined") return comparable.some((item) => match(condition, item));
|
|
9127
|
+
const positiveCondition = {
|
|
9128
|
+
...condition,
|
|
9129
|
+
operator: positive
|
|
9130
|
+
};
|
|
9131
|
+
return !comparable.some((item) => match(positiveCondition, item));
|
|
9132
|
+
};
|
|
9133
|
+
//#endregion
|
|
8417
9134
|
//#region src/rules/RulesEngine.ts
|
|
8418
9135
|
var RulesEngine = class {
|
|
8419
9136
|
order;
|
|
@@ -8450,20 +9167,20 @@ var RulesEngine = class {
|
|
|
8450
9167
|
if (!("tag" in condition) || !condition.tag) throw new Error("Invalid tag");
|
|
8451
9168
|
return condition.tag;
|
|
8452
9169
|
};
|
|
8453
|
-
matchOrderProductTag = (condition,
|
|
9170
|
+
matchOrderProductTag = (condition, kind, matchScalar) => {
|
|
8454
9171
|
const order = this.getDependency("order", condition.type);
|
|
8455
9172
|
const tag = this.getProductTag(condition);
|
|
8456
|
-
const
|
|
8457
|
-
|
|
9173
|
+
for (const line of order.lines ?? []) {
|
|
9174
|
+
const raw = line.tags?.[tag];
|
|
9175
|
+
if (typeof raw === "undefined") continue;
|
|
9176
|
+
if (matchTagLine(condition, kind, raw, matchScalar)) return true;
|
|
9177
|
+
}
|
|
8458
9178
|
return false;
|
|
8459
9179
|
};
|
|
8460
9180
|
assertString = (condition) => {
|
|
8461
9181
|
const { primitive, type } = condition;
|
|
8462
9182
|
assert(primitive === "string", primitiveError("string"));
|
|
8463
|
-
if (type === "Order includes product with tag") return this.matchOrderProductTag(condition,
|
|
8464
|
-
if (typeof value !== "string") return false;
|
|
8465
|
-
return orderIncludesProductWithTag(condition, value);
|
|
8466
|
-
});
|
|
9183
|
+
if (type === "Order includes product with tag") return this.matchOrderProductTag(condition, "string", orderIncludesProductWithTag);
|
|
8467
9184
|
if (type === "Request path") {
|
|
8468
9185
|
const monitoring = this.getDependency("monitoring", type);
|
|
8469
9186
|
return requestPath(condition, monitoring.path);
|
|
@@ -8489,10 +9206,7 @@ var RulesEngine = class {
|
|
|
8489
9206
|
const line = getLineByProductId(order.lines, productId);
|
|
8490
9207
|
return orderIncludesProduct(condition, line?.quantity);
|
|
8491
9208
|
}
|
|
8492
|
-
if (type === "Order includes product with tag") return this.matchOrderProductTag(condition,
|
|
8493
|
-
if (typeof value !== "number") return false;
|
|
8494
|
-
return orderIncludesProductWithTag$1(condition, value);
|
|
8495
|
-
});
|
|
9209
|
+
if (type === "Order includes product with tag") return this.matchOrderProductTag(condition, "number", orderIncludesProductWithTag$1);
|
|
8496
9210
|
if (type === "Accumulated events") {
|
|
8497
9211
|
const monitoring = this.getDependency("monitoring", type);
|
|
8498
9212
|
const window = windowMinutes(condition);
|
|
@@ -8525,10 +9239,7 @@ var RulesEngine = class {
|
|
|
8525
9239
|
const isAnonymous = this.getDependency("customer", type).is_anonymous;
|
|
8526
9240
|
return isAnonymousCustomer(condition, isAnonymous);
|
|
8527
9241
|
}
|
|
8528
|
-
if (type === "Order includes product with tag") return this.matchOrderProductTag(condition,
|
|
8529
|
-
if (typeof value !== "boolean") return false;
|
|
8530
|
-
return orderIncludesProductWithTag$3(condition, value);
|
|
8531
|
-
});
|
|
9242
|
+
if (type === "Order includes product with tag") return this.matchOrderProductTag(condition, "boolean", orderIncludesProductWithTag$3);
|
|
8532
9243
|
if (type === "Payment is mixed") {
|
|
8533
9244
|
const payment = this.getDependency("payment", type);
|
|
8534
9245
|
return paymentIsMixed(condition, payment.is_mixed);
|
|
@@ -8546,10 +9257,7 @@ var RulesEngine = class {
|
|
|
8546
9257
|
const order = this.getDependency("order", type);
|
|
8547
9258
|
return orderTotal(condition, order.total);
|
|
8548
9259
|
}
|
|
8549
|
-
if (type === "Order includes product with tag") return this.matchOrderProductTag(condition,
|
|
8550
|
-
if (typeof value !== "number") return false;
|
|
8551
|
-
return orderIncludesProductWithTag$2(condition, value);
|
|
8552
|
-
});
|
|
9260
|
+
if (type === "Order includes product with tag") return this.matchOrderProductTag(condition, "currency", orderIncludesProductWithTag$2);
|
|
8553
9261
|
throw new Error(`Condition ${type} not supported in currency matcher`);
|
|
8554
9262
|
};
|
|
8555
9263
|
assertEnum = (condition) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fire-spark/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
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.
|
|
24
|
-
"@fire-spark/types": "^0.
|
|
23
|
+
"@fire-spark/payments": "^0.12.1",
|
|
24
|
+
"@fire-spark/types": "^0.12.1"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"tsdown": "^0.22.14",
|