import type { CheckoutShipmentItem, Rule, RuleSet, RuleSets, RequirementCompare, RequirementCondition, } from '../../types'; type CompareValue = RequirementCondition['value']; export type SectionData = Record | undefined>; const ORDERING: Partial< Record boolean> > = { Gt: (a, b) => a > b, Gte: (a, b) => a >= b, Lt: (a, b) => a < b, Lte: (a, b) => a <= b, }; const isNullish = (v: unknown) => v === null || v === undefined || v === ''; /** Strings compare ignoring case, as the API does; anything else strictly. */ const equalsValue = (fieldValue: unknown, candidate: unknown): boolean => typeof fieldValue === 'string' && typeof candidate === 'string' ? fieldValue.toLowerCase() === candidate.toLowerCase() : fieldValue === candidate; /** * One field comparison as the quoting API does it (ported from ui-checkout). Ordering needs a * numeric field that satisfies every listed value. */ export function evaluateFieldComparison( fieldValue: unknown, compareValue: CompareValue, compareOp: RequirementCompare = 'Eq' ): boolean { const ordering = ORDERING[compareOp]; if (ordering) { if (typeof fieldValue !== 'number' || !Number.isFinite(fieldValue)) { return false; } const bounds = ( Array.isArray(compareValue) ? compareValue : [compareValue] ).map((v) => Number(v ?? 0)); return ( bounds.length > 0 && bounds.every((b) => Number.isFinite(b) && ordering(fieldValue, b)) ); } if (compareOp !== 'Eq' && compareOp !== 'NotEq') { return false; } let matches: boolean; if (compareValue === null) { matches = isNullish(fieldValue); } else if (Array.isArray(compareValue)) { matches = compareValue.some((v) => equalsValue(fieldValue, v)); } else { matches = equalsValue(fieldValue, compareValue); } return compareOp === 'Eq' ? matches : !matches; } /** `origin.companyStatus` is model `origin`, field `companyStatus`. */ function parseFieldModel(field: string, defaultModel: string) { const parts = field.split('.'); return parts.length > 1 ? { model: parts[parts.length - 2], field: parts[parts.length - 1] } : { model: defaultModel, field }; } function evaluateCondition( condition: RequirementCondition, data: SectionData, defaultModel: string, carriedValue?: CompareValue, hasCarriedValue = false ): boolean { const hasField = typeof condition.field === 'string'; const hasAll = Array.isArray(condition.all); const hasAny = Array.isArray(condition.any); // A node with no comparison and no child group carries nothing usable. if (!hasField && !hasAll && !hasAny) { return false; } const hasOwnValue = condition.value !== undefined; const compareValue = hasOwnValue ? condition.value : carriedValue; const hasCompareValue = hasOwnValue || hasCarriedValue; if (hasField) { // An absent value does not become required through `undefined === undefined`. if (!hasCompareValue) { return false; } const { model, field } = parseFieldModel( condition.field!, defaultModel ); if ( !evaluateFieldComparison( data[model]?.[field], compareValue, condition.compare || 'Eq' ) ) { return false; } } if ( hasAny && !condition.any!.some((child) => evaluateCondition( child, data, defaultModel, compareValue, hasCompareValue ) ) ) { return false; } if ( hasAll && !condition.all!.every((child) => evaluateCondition( child, data, defaultModel, compareValue, hasCompareValue ) ) ) { return false; } return true; } /** Whether a rule is required: its own flag, or its `requiredIf` holding for `data`. */ export function isRuleRequired( rule: Rule, data: SectionData, defaultModel = 'parcelContent' ): boolean { const requiredIf = rule.metadata?.requiredIf; if (!requiredIf || Object.keys(requiredIf).length === 0) { return rule.required; } return !!( rule.required || evaluateCondition(requiredIf, data, defaultModel) ); } /** A ruleset with each rule's `required` evaluated against `data`. */ export function mapRuleSet( ruleSet: RuleSet | undefined, data: SectionData, defaultModel = 'parcelContent' ): RuleSet | undefined { if (!ruleSet) { return undefined; } return { ...ruleSet, rules: (ruleSet.rules ?? []).map((rule) => ({ ...rule, required: isRuleRequired(rule, data, defaultModel), })), }; } /** * The data a rule's `requiredIf` reads, taken from the shipment that is sent (customs, origin, * destination, parcels). The duty term is derived as ui-checkout does: DDP, else `ioss` when an * IOSS number is given, else `ddu`. */ export function shipmentRuleData( shipment: CheckoutShipmentItem, ioss: string | null, /** The customer has opened the IOSS box but not added a number yet. */ iossIntended = false ): SectionData { return { customs: { exportReason: shipment.customs?.exportReason ?? null, dutyTerms: shipment.customs?.dutyTerms || (ioss || iossIntended ? 'ioss' : 'ddu'), ioss, }, origin: { ...shipment.origin?.useAddress }, destination: { ...shipment.destination?.useAddress }, parcels: { value: shipment.parcels?.[0]?.value ?? 0 }, }; } /** * Whether the IOSS number applies: the lane's customs ruleset has an `ioss` rule and the parcel * is at or under its `maxParcelValue` (minor units, converted from the EUR cap by the API). */ export function isIossEligible( ruleSets: RuleSets | null | undefined, parcelValue: number ): boolean { const cap = ruleSets?.customs?.rules?.find((rule) => rule.key === 'ioss') ?.metadata?.maxParcelValue; return typeof cap === 'number' && parcelValue <= cap; }