@nextcommerce/campaigns-os 1.33.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.
- package/AGENTS.md +204 -0
- package/CHANGELOG.md +5002 -0
- package/CONTEXT.md +685 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +368 -0
- package/agents/claude/CLAUDE.md +32 -0
- package/agents/codex/AGENTS.md +27 -0
- package/agents/copilot/copilot-instructions.md +14 -0
- package/agents/cursor/campaigns-os.mdc +13 -0
- package/bin/campaigns-os.mjs +38 -0
- package/campaign-spec/README.md +138 -0
- package/campaign-spec/dist/analytics-vocabulary.d.ts +47 -0
- package/campaign-spec/dist/analytics-vocabulary.js +74 -0
- package/campaign-spec/dist/index.d.ts +40 -0
- package/campaign-spec/dist/index.js +77 -0
- package/campaign-spec/dist/normalize.d.ts +22 -0
- package/campaign-spec/dist/normalize.js +41 -0
- package/campaign-spec/dist/routing.d.ts +190 -0
- package/campaign-spec/dist/routing.js +263 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +303 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.js +191 -0
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +14 -0
- package/campaign-spec/dist/rules/campaign-metadata.js +40 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.js +66 -0
- package/campaign-spec/dist/rules/cycle-detection.d.ts +12 -0
- package/campaign-spec/dist/rules/cycle-detection.js +141 -0
- package/campaign-spec/dist/rules/design-source-shape.d.ts +29 -0
- package/campaign-spec/dist/rules/design-source-shape.js +142 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.d.ts +11 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.js +40 -0
- package/campaign-spec/dist/rules/exit-intent-validation.d.ts +23 -0
- package/campaign-spec/dist/rules/exit-intent-validation.js +147 -0
- package/campaign-spec/dist/rules/funnel-count.d.ts +9 -0
- package/campaign-spec/dist/rules/funnel-count.js +37 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.d.ts +22 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.js +60 -0
- package/campaign-spec/dist/rules/funnel-identity.d.ts +15 -0
- package/campaign-spec/dist/rules/funnel-identity.js +57 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.d.ts +20 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.js +66 -0
- package/campaign-spec/dist/rules/index.d.ts +69 -0
- package/campaign-spec/dist/rules/index.js +131 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.d.ts +13 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.js +59 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.d.ts +12 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.js +42 -0
- package/campaign-spec/dist/rules/page-count.d.ts +11 -0
- package/campaign-spec/dist/rules/page-count.js +31 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.d.ts +14 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.js +47 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.d.ts +8 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.js +126 -0
- package/campaign-spec/dist/rules/promo-codes-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/promo-codes-shape.js +187 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.d.ts +33 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.js +81 -0
- package/campaign-spec/dist/rules/route-target-resolves.d.ts +32 -0
- package/campaign-spec/dist/rules/route-target-resolves.js +112 -0
- package/campaign-spec/dist/rules/schema-version.d.ts +21 -0
- package/campaign-spec/dist/rules/schema-version.js +53 -0
- package/campaign-spec/dist/rules/sdk-version.d.ts +26 -0
- package/campaign-spec/dist/rules/sdk-version.js +96 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.js +30 -0
- package/campaign-spec/dist/rules/shipping-methods-present.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-methods-present.js +26 -0
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/store-profile-shape.js +127 -0
- package/campaign-spec/dist/rules/thank-you-requirement.d.ts +16 -0
- package/campaign-spec/dist/rules/thank-you-requirement.js +50 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.d.ts +28 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.js +114 -0
- package/campaign-spec/dist/rules/upsell-has-packages.d.ts +9 -0
- package/campaign-spec/dist/rules/upsell-has-packages.js +35 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.d.ts +10 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.js +45 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.d.ts +11 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.js +44 -0
- package/campaign-spec/dist/rules/variant-labels-shape.d.ts +28 -0
- package/campaign-spec/dist/rules/variant-labels-shape.js +86 -0
- package/campaign-spec/dist/sdk-version-parse.d.ts +43 -0
- package/campaign-spec/dist/sdk-version-parse.js +62 -0
- package/campaign-spec/dist/types.d.ts +674 -0
- package/campaign-spec/dist/types.js +40 -0
- package/campaign-spec/package.json +12 -0
- package/compatibility.json +25 -0
- package/contracts/agent-relevant-change-policy.v1.json +111 -0
- package/contracts/brand-theme-source-defaults.figma-sections-export.v0.json +32 -0
- package/contracts/brand-theme-target-tokens.next-core.v0.json +65 -0
- package/contracts/campaign-cart-checkout-field-contract.v0.json +45 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +11 -0
- package/contracts/commerce-surface-catalog.json +2452 -0
- package/contracts/fixtures/orientation/canonicalization/v1.json +34 -0
- package/contracts/fixtures/orientation/envelope/current.json +95 -0
- package/contracts/fixtures/orientation/envelope/freshness_unknown.json +97 -0
- package/contracts/fixtures/orientation/envelope/legacy_baseline.json +95 -0
- package/contracts/fixtures/orientation/envelope/orientation_available.json +136 -0
- package/contracts/fixtures/orientation/envelope/recovered_interrupted_update.json +136 -0
- package/contracts/fixtures/orientation/envelope/refused.json +100 -0
- package/contracts/fixtures/orientation/envelope/restart_required.json +137 -0
- package/contracts/fixtures/orientation/envelope/updated.json +135 -0
- package/contracts/fixtures/orientation/hostile-target/README.md +61 -0
- package/contracts/fixtures/orientation/hostile-target/manifest.json +82 -0
- package/contracts/fixtures/orientation/hostile-target/repo/CHANGELOG.md +18 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/intended.mjs +12 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/tripwire.mjs +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/release-ledger.json +48 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/supported-surface.json +17 -0
- package/contracts/fixtures/orientation/hostile-target/repo/docs/example-contract.md +13 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-checkout +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-merge +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/pre-commit +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/package.json +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/tripwire.mjs +9 -0
- package/contracts/fixtures/orientation/hostile-target/repo/package.json +20 -0
- package/contracts/fixtures/orientation/hostile-target/repo/schemas/example.v0.schema.json +15 -0
- package/contracts/fixtures/orientation/release-gate/cases.json +1073 -0
- package/contracts/fixtures/runtime-recipe/accept/current.json +299 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +294 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +51 -0
- package/contracts/fixtures/runtime-recipe/manifest.json +85 -0
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +297 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +251 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +310 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +299 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/assembly-report.json +180 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/build-context.json +115 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/doctor-output.json +29 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/qa-verdict.json +27 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/campaign-runtime.build.json +141 -0
- package/contracts/migration-sidecar-bundle.v0.json +149 -0
- package/contracts/orientation-limits.v1.json +41 -0
- package/contracts/orientation-reason-codes.v1.json +196 -0
- package/contracts/private-template-sources.json +8 -0
- package/contracts/release-ledger.json +4598 -0
- package/contracts/reserved-skill-names.json +13 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +299 -0
- package/contracts/supported-surface.json +180 -0
- package/contracts/template-brand-contract.apollo-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.apollo.v0.json +27 -0
- package/contracts/template-brand-contract.demeter.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-two-step.v0.json +28 -0
- package/contracts/template-brand-contract.olympus.v0.json +27 -0
- package/contracts/template-brand-contract.shared-commerce.v0.json +190 -0
- package/contracts/template-brand-contract.shop-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.shop-three-step.v0.json +29 -0
- package/contracts/template-slot-manifest.apollo-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.apollo.v0.json +12 -0
- package/contracts/template-slot-manifest.demeter.v0.json +30 -0
- package/contracts/template-slot-manifest.olympus-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.olympus-mv-two-step.v0.json +15 -0
- package/contracts/template-slot-manifest.olympus.v0.json +12 -0
- package/contracts/template-slot-manifest.shared-content-core.v0.json +4254 -0
- package/contracts/template-slot-manifest.shop-single-step.v0.json +47 -0
- package/contracts/template-slot-manifest.shop-three-step.v0.json +33 -0
- package/docs/brand-theme-bridge.md +159 -0
- package/docs/build-packet.md +1300 -0
- package/docs/campaign-build-brief.md +145 -0
- package/docs/campaign-standardization-report.md +329 -0
- package/docs/campaigns-os-build-flow.md +117 -0
- package/docs/design-source-package.md +784 -0
- package/docs/legacy-migration.md +58 -0
- package/docs/migration-sidecar-bundle.md +139 -0
- package/docs/orientation-contract-reference.md +1220 -0
- package/docs/polish-evidence.md +502 -0
- package/docs/qa-and-test-orders.md +1691 -0
- package/docs/release-ledger-authoring-guide.md +274 -0
- package/docs/runtime-readiness.md +211 -0
- package/docs/supported-surface.md +83 -0
- package/docs/versioning.md +55 -0
- package/docs/workflow-findings-sidecar.md +588 -0
- package/package.json +135 -0
- package/prompts/first-build.md +27 -0
- package/prompts/friction-log.md +30 -0
- package/schemas/campaign-build-brief.v1.schema.json +149 -0
- package/schemas/campaign-design-source-package.v0.schema.json +697 -0
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +390 -0
- package/schemas/campaign-runtime-build-context.v0.schema.json +339 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +452 -0
- package/schemas/campaign-spec.v4.schema.json +582 -0
- package/schemas/campaigns-os-doctor-output.v0.schema.json +43 -0
- package/schemas/campaigns-os-legacy-migration-inventory.v0.schema.json +112 -0
- package/schemas/campaigns-os-legacy-provisioning-plan.v0.schema.json +38 -0
- package/schemas/campaigns-os-legacy-provisioning-receipt.v0.schema.json +57 -0
- package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +150 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +438 -0
- package/schemas/campaigns-os-release-ledger.v1.schema.json +162 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +343 -0
- package/schemas/campaigns-os-runtime-recipe.v1.schema.json +313 -0
- package/schemas/campaigns-os-sidecar-bundle-conformance.v0.schema.json +78 -0
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +381 -0
- package/schemas/campaigns-os-workflow-finding.v0.schema.json +134 -0
- package/schemas/source-html-manifest.v0.schema.json +252 -0
- package/skills/next-campaigns-build/SKILL.md +73 -0
- package/skills/next-campaigns-os/SKILL.md +101 -0
- package/skills/next-campaigns-os/references/session-intake.md +160 -0
- package/skills/next-campaigns-os-setup/SKILL.md +22 -0
- package/skills/next-campaigns-polish/SKILL.md +145 -0
- package/skills/next-campaigns-qa/SKILL.md +92 -0
- package/skills.json +56 -0
- package/skills.sh +64 -0
- package/src/adapter-decision-contract.mjs +333 -0
- package/src/brand-theme.mjs +1151 -0
- package/src/browser-launch.mjs +79 -0
- package/src/build-brief.mjs +781 -0
- package/src/built-site-scope.mjs +312 -0
- package/src/campaign-ecosystem.mjs +734 -0
- package/src/campaign-identity.mjs +405 -0
- package/src/campaign-workspace.mjs +127 -0
- package/src/checkpoint-waiver.mjs +302 -0
- package/src/cli.mjs +13442 -0
- package/src/commercial-journey.mjs +1119 -0
- package/src/commercial-parity.mjs +965 -0
- package/src/consent.mjs +347 -0
- package/src/content-residue.mjs +322 -0
- package/src/deadline.mjs +81 -0
- package/src/design-source-package.mjs +2604 -0
- package/src/deviation.mjs +107 -0
- package/src/doctor-check-registry.mjs +49 -0
- package/src/doctor-sidecar.mjs +106 -0
- package/src/finding-cause.mjs +557 -0
- package/src/findings.mjs +326 -0
- package/src/fs-identity.mjs +68 -0
- package/src/gate-actions.mjs +105 -0
- package/src/html-scan.mjs +53 -0
- package/src/install-mode.mjs +272 -0
- package/src/legacy-migration.d.ts +128 -0
- package/src/legacy-migration.mjs +510 -0
- package/src/lifecycle.mjs +338 -0
- package/src/local-proof.mjs +401 -0
- package/src/map-pin-writeback.mjs +210 -0
- package/src/orchestration-stage-contract.mjs +81 -0
- package/src/package-install-fixture.mjs +33 -0
- package/src/page-kit-build-summary.mjs +175 -0
- package/src/page-kit-campaign-config.mjs +57 -0
- package/src/page-kit-sdk-version.mjs +392 -0
- package/src/page-kit-store-profile.mjs +369 -0
- package/src/page-kit-sync.mjs +162 -0
- package/src/polish-browser.mjs +867 -0
- package/src/polish-capture.mjs +1094 -0
- package/src/polish-deadline.mjs +51 -0
- package/src/polish-gate.mjs +739 -0
- package/src/polish-node.mjs +639 -0
- package/src/polish-page-load.mjs +1100 -0
- package/src/private-template-source.mjs +237 -0
- package/src/proof-policy.mjs +82 -0
- package/src/qa-analytics-correctness.mjs +307 -0
- package/src/qa-analytics-errors.mjs +38 -0
- package/src/qa-analytics-parity.mjs +699 -0
- package/src/qa-binding-evidence.mjs +140 -0
- package/src/qa-browser.mjs +6608 -0
- package/src/qa-cart-entry.mjs +406 -0
- package/src/qa-commercial-parity.mjs +641 -0
- package/src/qa-node.mjs +3620 -0
- package/src/qa-order-bump.mjs +381 -0
- package/src/qa-parity-capture.mjs +428 -0
- package/src/qa-parity-fixture.mjs +359 -0
- package/src/qa-publish.mjs +362 -0
- package/src/qa-purchase-data-layer.mjs +263 -0
- package/src/qa-route-probe.mjs +272 -0
- package/src/qa-sidecar.mjs +188 -0
- package/src/qa-test-order-topology.mjs +207 -0
- package/src/qa-url-privacy.mjs +13 -0
- package/src/qa-verdict-discovery.mjs +192 -0
- package/src/qa-verdict-publish.mjs +105 -0
- package/src/qa-verdict.mjs +287 -0
- package/src/remit.mjs +388 -0
- package/src/repo-scan.mjs +83 -0
- package/src/route-identity.mjs +133 -0
- package/src/run-record-closeout.mjs +229 -0
- package/src/run-record.mjs +839 -0
- package/src/run-session.mjs +226 -0
- package/src/runtime-state-ignore.mjs +113 -0
- package/src/sdk-attribute-index.mjs +212 -0
- package/src/sdk-markup.mjs +358 -0
- package/src/sdk-meta-tags.mjs +52 -0
- package/src/shell-token.mjs +7 -0
- package/src/sidecar-bundle.mjs +399 -0
- package/src/source-asset-crawl.mjs +469 -0
- package/src/source-html-intake.mjs +627 -0
- package/src/source-html-manifest.mjs +276 -0
- package/src/source-prep.mjs +284 -0
- package/src/spec-derive-store.mjs +431 -0
- package/src/spec-derive.mjs +514 -0
- package/src/spec-fetch.mjs +66 -0
- package/src/spec-hash.mjs +48 -0
- package/src/spec-identity.mjs +27 -0
- package/src/stage-ledger.mjs +509 -0
- package/src/standardization-report.mjs +1297 -0
- package/src/template-brand-contract.mjs +473 -0
- package/src/template-freshness.mjs +196 -0
- package/src/template-reference.mjs +81 -0
- package/src/template-slot-manifest.mjs +150 -0
- package/src/text-safety.mjs +62 -0
- package/src/theme-gate.mjs +185 -0
- package/src/upsell-selector-scope.mjs +299 -0
|
@@ -0,0 +1,1119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure Commercial Journey scenario planning and response normalization.
|
|
3
|
+
*
|
|
4
|
+
* This module intentionally owns no transport. Callers execute the returned
|
|
5
|
+
* plan and pass the captured results to normalizeJourney.
|
|
6
|
+
*
|
|
7
|
+
* Deviation from design §5 (ratified 2026-08-21): voucher-type offers attached
|
|
8
|
+
* to upsell/downsell pages are treated as page-carried (per-OTO wiring) and
|
|
9
|
+
* priced with-and-without; surface-bound codes remain the only voucher path on
|
|
10
|
+
* checkout pages.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { specHashOf, specHashesMatch } from "./spec-hash.mjs";
|
|
14
|
+
|
|
15
|
+
export const CALC_LABEL = "Campaigns-calculated · before tax";
|
|
16
|
+
export const CALCULATED_PAIR_EVIDENCE = "calculated_pair";
|
|
17
|
+
|
|
18
|
+
export const PricingState = Object.freeze({
|
|
19
|
+
Exact: "Exact",
|
|
20
|
+
Stale: "Stale",
|
|
21
|
+
Unresolved: "Unresolved",
|
|
22
|
+
Estimated: "Estimated",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export class CommercialJourneyLimitError extends Error {
|
|
26
|
+
constructor(kind, limit) {
|
|
27
|
+
super(`Commercial journey ${kind} limit exceeded (${limit}).`);
|
|
28
|
+
this.name = "CommercialJourneyLimitError";
|
|
29
|
+
this.code = `commercial_journey_${kind}_limit`;
|
|
30
|
+
this.kind = kind;
|
|
31
|
+
this.limit = limit;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const UNRESOLVED_PACKAGE = "Unresolved: package not on campaign";
|
|
36
|
+
const UNRESOLVED_STORE_COUPON = "Unresolved: store coupon — not previewable";
|
|
37
|
+
const UNRESOLVED_INVALID_PACKAGE = "Unresolved: invalid package row";
|
|
38
|
+
const UNRESOLVED_INVALID_SHIPPING = "Unresolved: invalid shipping method";
|
|
39
|
+
const UNRESOLVED_INVALID_RECURRENCE = "Unresolved: invalid recurring package facts";
|
|
40
|
+
|
|
41
|
+
function array(value) {
|
|
42
|
+
return Array.isArray(value) ? value : [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function present(value) {
|
|
46
|
+
return value !== undefined && value !== null && String(value).trim() !== "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function positiveInteger(value) {
|
|
50
|
+
if (typeof value !== "number" && typeof value !== "string") return null;
|
|
51
|
+
if (typeof value === "string" && value.trim() === "") return null;
|
|
52
|
+
const parsed = Number(value);
|
|
53
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function pageKey(page) {
|
|
57
|
+
return encodeURIComponent(String(page?.id || "page"));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function mapOffers(mapDoc) {
|
|
61
|
+
return [
|
|
62
|
+
...array(mapDoc?.offers),
|
|
63
|
+
...array(mapDoc?.campaign?.offers),
|
|
64
|
+
...array(mapDoc?.catalog?.offers),
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function offerIdentity(offer) {
|
|
69
|
+
return String(offer?.ref_id ?? offer?.id ?? "");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function resolveOffer(entry, offers) {
|
|
73
|
+
const ref = offerIdentity(entry);
|
|
74
|
+
const resolved = offers.find((offer) => offerIdentity(offer) === ref);
|
|
75
|
+
return { ...(resolved || {}), ...(entry || {}) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function selectedShipping(page) {
|
|
79
|
+
if (present(page?.shipping_method)) {
|
|
80
|
+
const selected = positiveInteger(page.shipping_method);
|
|
81
|
+
return selected === null
|
|
82
|
+
? { reason: UNRESOLVED_INVALID_SHIPPING }
|
|
83
|
+
: { value: selected };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const candidates = array(page?.shipping_methods);
|
|
87
|
+
if (!candidates.length) return {};
|
|
88
|
+
|
|
89
|
+
const ranked = candidates.map((method, index) => {
|
|
90
|
+
const raw = method && typeof method === "object" ? method : { ref_id: method };
|
|
91
|
+
const ref = raw.ref_id ?? raw.id ?? raw.shipping_method_id ?? raw.code;
|
|
92
|
+
const cents = moneyToCents(raw.price);
|
|
93
|
+
return { ref: positiveInteger(ref), cents, index };
|
|
94
|
+
}).filter((entry) => present(entry.ref));
|
|
95
|
+
if (!ranked.length) return { reason: UNRESOLVED_INVALID_SHIPPING };
|
|
96
|
+
|
|
97
|
+
ranked.sort((a, b) => {
|
|
98
|
+
if (a.cents === null && b.cents === null) return a.index - b.index;
|
|
99
|
+
if (a.cents === null) return 1;
|
|
100
|
+
if (b.cents === null) return -1;
|
|
101
|
+
return a.cents - b.cents || a.index - b.index;
|
|
102
|
+
});
|
|
103
|
+
return { value: ranked[0].ref };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// One predicate for "this checkout row is an order bump": the spec marks a
|
|
107
|
+
// bump with `is_upsell: true` on a non-upsell page. Every consumer that must
|
|
108
|
+
// tell a bump from a main selector row (parity scenarios, bump deltas, the QA
|
|
109
|
+
// selector-tier planner) reads this, so a marker change lands in one place.
|
|
110
|
+
export function isBumpRow(row) {
|
|
111
|
+
return Boolean(row?.is_upsell);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function lineForRow(row) {
|
|
115
|
+
const packageId = positiveInteger(row?.ref_id);
|
|
116
|
+
const quantity = present(row?.qty) ? positiveInteger(row.qty) : 1;
|
|
117
|
+
if (packageId === null || quantity === null) return null;
|
|
118
|
+
const line = { package_id: packageId, quantity };
|
|
119
|
+
if (isBumpRow(row)) line.is_upsell = true;
|
|
120
|
+
return line;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function authoredLine(row) {
|
|
124
|
+
return {
|
|
125
|
+
package_id: row?.ref_id ?? null,
|
|
126
|
+
quantity: present(row?.qty) ? row.qty : 1,
|
|
127
|
+
...(isBumpRow(row) ? { is_upsell: true } : {}),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function findFunnel(mapDoc, page) {
|
|
132
|
+
return array(mapDoc?.funnels).find((funnel) =>
|
|
133
|
+
array(funnel?.pages).some((candidate) => String(candidate?.id) === String(page?.id)),
|
|
134
|
+
) || null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function declineCondition(mapDoc, page) {
|
|
138
|
+
for (const funnel of array(mapDoc?.funnels)) {
|
|
139
|
+
const parent = array(funnel?.pages).find((candidate) =>
|
|
140
|
+
String(candidate?.on_decline ?? "") === String(page?.id ?? ""),
|
|
141
|
+
);
|
|
142
|
+
if (parent) return `if ${parent.label || parent.id} declined`;
|
|
143
|
+
}
|
|
144
|
+
return page?.type === "downsell" ? "if prior upsell declined" : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function catalogImportedAt(mapDoc) {
|
|
148
|
+
return mapDoc?.catalog_imported_at
|
|
149
|
+
?? mapDoc?._provenance?.api?.imported_at
|
|
150
|
+
?? mapDoc?._provenance?.api?.fetched_at
|
|
151
|
+
?? mapDoc?.campaign?._provenance?.api?.imported_at
|
|
152
|
+
?? null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function descriptor(page, mapDoc, role, lines, options = {}) {
|
|
156
|
+
const funnel = findFunnel(mapDoc, page);
|
|
157
|
+
const suffix = options.id_suffix ? `:${options.id_suffix}` : "";
|
|
158
|
+
const result = {
|
|
159
|
+
id: `commercial:${pageKey(page)}:${role}${suffix}`,
|
|
160
|
+
upsell: page?.type === "upsell" || page?.type === "downsell",
|
|
161
|
+
context: {
|
|
162
|
+
page_id: page?.id ?? null,
|
|
163
|
+
page_type: page?.type ?? null,
|
|
164
|
+
page_label: page?.label ?? page?.name ?? page?.id ?? null,
|
|
165
|
+
page_order: Number(page?.order) || 0,
|
|
166
|
+
funnel_id: funnel?.id ?? null,
|
|
167
|
+
role,
|
|
168
|
+
row_index: options.row_index,
|
|
169
|
+
bump_index: options.bump_index,
|
|
170
|
+
surface: options.surface,
|
|
171
|
+
offer_code: options.offer_code,
|
|
172
|
+
offer_ref_id: options.offer_ref_id,
|
|
173
|
+
attached_offers: options.attached_offers || [],
|
|
174
|
+
known_offer_codes: options.known_offer_codes || [],
|
|
175
|
+
page_rows: options.page_rows || [],
|
|
176
|
+
decline_condition: declineCondition(mapDoc, page),
|
|
177
|
+
spec_hash: specHashOf(mapDoc),
|
|
178
|
+
catalog_imported_at: catalogImportedAt(mapDoc),
|
|
179
|
+
planned_rows: options.planned_rows || [],
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
if (options.unresolved) {
|
|
183
|
+
return { ...result, unresolved: true, reason: options.reason };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const body = { lines: lines.map((line) => ({ ...line })) };
|
|
187
|
+
if (array(options.vouchers).length) body.vouchers = [...options.vouchers];
|
|
188
|
+
|
|
189
|
+
const currency = mapDoc?.campaign?.currency ?? mapDoc?.currency;
|
|
190
|
+
if (present(currency)) body.currency = String(currency);
|
|
191
|
+
if (present(options.shipping_method)) body.shipping_method = options.shipping_method;
|
|
192
|
+
return { ...result, body };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Convert one authored page into deterministic calculate request descriptors.
|
|
197
|
+
*/
|
|
198
|
+
export function planScenarios(page, mapDoc = {}, options = {}) {
|
|
199
|
+
const rows = array(page?.packages);
|
|
200
|
+
if (!rows.length) return [];
|
|
201
|
+
const requestedLimit = Number(options.maxScenarios);
|
|
202
|
+
const maxScenarios = Number.isInteger(requestedLimit) && requestedLimit >= 0
|
|
203
|
+
? requestedLimit
|
|
204
|
+
: Number.POSITIVE_INFINITY;
|
|
205
|
+
if (rows.length > maxScenarios) throw new CommercialJourneyLimitError("scenarios", maxScenarios);
|
|
206
|
+
|
|
207
|
+
// Descriptor ids are namespaced by page id; two id-less pages would collide
|
|
208
|
+
// and silently consume each other's responses in a shared plan. Refuse to
|
|
209
|
+
// price such a page instead of guessing.
|
|
210
|
+
if (!present(page?.id)) {
|
|
211
|
+
if (maxScenarios < 1) throw new CommercialJourneyLimitError("scenarios", maxScenarios);
|
|
212
|
+
return [descriptor(page, mapDoc, "representative", [], {
|
|
213
|
+
unresolved: true,
|
|
214
|
+
reason: "Unresolved: page has no id",
|
|
215
|
+
planned_rows: rows.map(authoredLine),
|
|
216
|
+
})];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const offers = mapOffers(mapDoc);
|
|
220
|
+
const attached = array(page?.offers).map((entry) => resolveOffer(entry, offers));
|
|
221
|
+
const pageIsUpsell = page?.type === "upsell" || page?.type === "downsell";
|
|
222
|
+
const knownOfferCodes = [...new Set([
|
|
223
|
+
...offers,
|
|
224
|
+
...attached,
|
|
225
|
+
].map((offer) => offer?.code).filter(present).map(String))];
|
|
226
|
+
|
|
227
|
+
const surfaces = ["promo_code_input", "exit_intent"]
|
|
228
|
+
.map((name) => ({ name, value: page?.[name] }))
|
|
229
|
+
.filter(({ value }) => value?.enabled !== false && present(value?.offer_code));
|
|
230
|
+
const carriedVouchers = attached
|
|
231
|
+
.filter((offer) => pageIsUpsell && offer?.type === "voucher" && present(offer?.code))
|
|
232
|
+
.map((offer) => String(offer.code));
|
|
233
|
+
const plannedSurfaces = surfaces.filter(({ value }) =>
|
|
234
|
+
!pageIsUpsell || !carriedVouchers.includes(String(value.offer_code)),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const shared = { attached_offers: attached, known_offer_codes: knownOfferCodes, page_rows: rows };
|
|
238
|
+
const plan = [];
|
|
239
|
+
const add = (value) => {
|
|
240
|
+
if (plan.length >= maxScenarios) throw new CommercialJourneyLimitError("scenarios", maxScenarios);
|
|
241
|
+
plan.push(value);
|
|
242
|
+
};
|
|
243
|
+
const parsedRows = rows.map((row, rowIndex) => ({
|
|
244
|
+
row,
|
|
245
|
+
rowIndex,
|
|
246
|
+
line: lineForRow(row),
|
|
247
|
+
authored: authoredLine(row),
|
|
248
|
+
}));
|
|
249
|
+
const mainRows = pageIsUpsell ? parsedRows : parsedRows.filter(({ row }) => !isBumpRow(row));
|
|
250
|
+
const bumpRows = pageIsUpsell ? [] : parsedRows.filter(({ row }) => isBumpRow(row));
|
|
251
|
+
const representativeLines = mainRows.map(({ line }) => line).filter(Boolean);
|
|
252
|
+
const shipping = selectedShipping(page);
|
|
253
|
+
const representativeReason = shipping.reason
|
|
254
|
+
|| (mainRows.length === 0 || mainRows.some(({ line }) => !line) ? UNRESOLVED_INVALID_PACKAGE : null);
|
|
255
|
+
const describe = (role, lines, options = {}) => {
|
|
256
|
+
const reason = options.reason || shipping.reason;
|
|
257
|
+
return descriptor(page, mapDoc, role, lines, {
|
|
258
|
+
...options,
|
|
259
|
+
...shared,
|
|
260
|
+
shipping_method: shipping.value,
|
|
261
|
+
...(reason ? { unresolved: true, reason } : {}),
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
if (representativeReason) {
|
|
266
|
+
add(describe("representative", [], {
|
|
267
|
+
reason: representativeReason,
|
|
268
|
+
planned_rows: mainRows.map(({ authored }) => authored),
|
|
269
|
+
}));
|
|
270
|
+
} else if (representativeLines.length) {
|
|
271
|
+
if (pageIsUpsell && carriedVouchers.length) {
|
|
272
|
+
add(describe("representative-without", representativeLines));
|
|
273
|
+
add(describe("representative-with", representativeLines, {
|
|
274
|
+
vouchers: carriedVouchers,
|
|
275
|
+
}));
|
|
276
|
+
} else {
|
|
277
|
+
add(describe("representative", representativeLines));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
bumpRows.forEach(({ line, authored }, bumpIndex) => {
|
|
282
|
+
const reason = representativeReason || (!line ? UNRESOLVED_INVALID_PACKAGE : null);
|
|
283
|
+
add(describe("bump-without", representativeLines, {
|
|
284
|
+
bump_index: bumpIndex,
|
|
285
|
+
id_suffix: bumpIndex,
|
|
286
|
+
reason,
|
|
287
|
+
}));
|
|
288
|
+
add(describe("bump-with", line ? [...representativeLines, line] : [], {
|
|
289
|
+
bump_index: bumpIndex,
|
|
290
|
+
id_suffix: bumpIndex,
|
|
291
|
+
reason,
|
|
292
|
+
planned_rows: !line ? [authored] : [],
|
|
293
|
+
}));
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
parsedRows.forEach(({ line, authored, rowIndex }) => {
|
|
297
|
+
const reason = shipping.reason || (!line ? UNRESOLVED_INVALID_PACKAGE : null);
|
|
298
|
+
add(describe("row", line ? [line] : [], {
|
|
299
|
+
row_index: rowIndex,
|
|
300
|
+
id_suffix: rowIndex,
|
|
301
|
+
vouchers: pageIsUpsell ? carriedVouchers : [],
|
|
302
|
+
reason,
|
|
303
|
+
planned_rows: reason ? [authored] : [],
|
|
304
|
+
}));
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
plannedSurfaces.forEach(({ name, value }, surfaceIndex) => {
|
|
308
|
+
const baselineVouchers = pageIsUpsell ? carriedVouchers : [];
|
|
309
|
+
const surfaceOptions = {
|
|
310
|
+
...shared,
|
|
311
|
+
surface: name,
|
|
312
|
+
offer_code: String(value.offer_code),
|
|
313
|
+
offer_ref_id: value.offer_ref_id ?? null,
|
|
314
|
+
id_suffix: `${surfaceIndex}:${encodeURIComponent(String(value.offer_code))}`,
|
|
315
|
+
};
|
|
316
|
+
add(describe("surface-without", representativeLines, {
|
|
317
|
+
...surfaceOptions,
|
|
318
|
+
vouchers: baselineVouchers,
|
|
319
|
+
reason: representativeReason,
|
|
320
|
+
}));
|
|
321
|
+
add(describe("surface-with", representativeLines, {
|
|
322
|
+
...surfaceOptions,
|
|
323
|
+
vouchers: [...baselineVouchers, String(value.offer_code)],
|
|
324
|
+
reason: representativeReason,
|
|
325
|
+
}));
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
return plan;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function metadata(value) {
|
|
332
|
+
return value?.meta || value?.context || value || {};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function unresolvedResult(result) {
|
|
336
|
+
return result?.state === PricingState.Unresolved || result?.unresolved === true || result?.ok === false;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Derive the freshness state of a calculation result against current metadata.
|
|
341
|
+
*/
|
|
342
|
+
export function deriveState(result, meta = {}) {
|
|
343
|
+
if (unresolvedResult(result)) return PricingState.Unresolved;
|
|
344
|
+
if (result?.state === PricingState.Stale) return PricingState.Stale;
|
|
345
|
+
|
|
346
|
+
const calculated = metadata(result);
|
|
347
|
+
const resultSpecHash = calculated.spec_hash ?? result?.spec_hash;
|
|
348
|
+
const resultCatalog = calculated.catalog_imported_at ?? result?.catalog_imported_at;
|
|
349
|
+
const resultCalculatedAt = calculated.calculated_at ?? result?.calculated_at ?? meta?.calculated_at;
|
|
350
|
+
|
|
351
|
+
if (present(resultSpecHash) && present(meta?.spec_hash) && !specHashesMatch(resultSpecHash, meta.spec_hash)) {
|
|
352
|
+
return PricingState.Stale;
|
|
353
|
+
}
|
|
354
|
+
if (present(resultCatalog) && present(meta?.catalog_imported_at) && String(resultCatalog) !== String(meta.catalog_imported_at)) {
|
|
355
|
+
return PricingState.Stale;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const catalogTime = Date.parse(meta?.catalog_imported_at);
|
|
359
|
+
const calculatedTime = Date.parse(resultCalculatedAt);
|
|
360
|
+
if (Number.isFinite(catalogTime) && Number.isFinite(calculatedTime) && catalogTime > calculatedTime) {
|
|
361
|
+
return PricingState.Stale;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return PricingState.Exact;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function moneyToCents(value) {
|
|
368
|
+
if (typeof value === "number" && Number.isInteger(value)) return value;
|
|
369
|
+
if (!present(value)) return null;
|
|
370
|
+
const match = String(value).trim().match(/^([+-]?)(\d+)(?:\.(\d{1,2}))?$/);
|
|
371
|
+
if (!match) return null;
|
|
372
|
+
const fraction = (match[3] || "").padEnd(2, "0");
|
|
373
|
+
const cents = Number(match[2]) * 100 + Number(fraction || 0);
|
|
374
|
+
return match[1] === "-" ? -cents : cents;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function centsToUsd(cents) {
|
|
378
|
+
if (!Number.isInteger(cents)) return null;
|
|
379
|
+
const sign = cents < 0 ? "-" : "";
|
|
380
|
+
const absolute = Math.abs(cents);
|
|
381
|
+
return `${sign}${Math.floor(absolute / 100)}.${String(absolute % 100).padStart(2, "0")}`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function signedCents(cents) {
|
|
385
|
+
if (!Number.isInteger(cents)) return null;
|
|
386
|
+
if (cents > 0) return `+${centsToUsd(cents)}`;
|
|
387
|
+
return centsToUsd(cents);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function moneyFact(value, state) {
|
|
391
|
+
return { state, value: String(value), label: CALC_LABEL };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function unresolvedFact(reason) {
|
|
395
|
+
return { state: PricingState.Unresolved, reason };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function statusOf(envelope) {
|
|
399
|
+
return Number(envelope?.status ?? envelope?.statusCode ?? 200);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function payloadOf(envelope) {
|
|
403
|
+
if (envelope && Object.prototype.hasOwnProperty.call(envelope, "response")) return envelope.response;
|
|
404
|
+
if (envelope && Object.prototype.hasOwnProperty.call(envelope, "body")) return envelope.body;
|
|
405
|
+
return envelope;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function retryAfter(envelope) {
|
|
409
|
+
const headers = envelope?.headers || {};
|
|
410
|
+
if (typeof headers.get === "function") return headers.get("Retry-After") || headers.get("retry-after") || "unknown";
|
|
411
|
+
return headers["Retry-After"] ?? headers["retry-after"] ?? envelope?.retry_after ?? "unknown";
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function sameRequest(descriptorValue, envelope) {
|
|
415
|
+
if (!envelope?.request) return false;
|
|
416
|
+
const expectedUpsell = descriptorValue.upsell;
|
|
417
|
+
const actualUpsell = /[?&]upsell=true(?:&|$)/.test(String(envelope.request.url || ""));
|
|
418
|
+
if (expectedUpsell !== actualUpsell) return false;
|
|
419
|
+
return JSON.stringify(descriptorValue.body) === JSON.stringify(envelope.request.body);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function responseLookup(plan, responses) {
|
|
423
|
+
if (!Array.isArray(responses) && responses && typeof responses === "object") {
|
|
424
|
+
return (descriptorValue) => responses[descriptorValue.id];
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const list = array(responses);
|
|
428
|
+
const used = new Set();
|
|
429
|
+
return (descriptorValue, index) => {
|
|
430
|
+
const byId = list.findIndex((entry, candidateIndex) =>
|
|
431
|
+
!used.has(candidateIndex) && String(entry?.id ?? "") === descriptorValue.id,
|
|
432
|
+
);
|
|
433
|
+
if (byId >= 0) {
|
|
434
|
+
used.add(byId);
|
|
435
|
+
return list[byId];
|
|
436
|
+
}
|
|
437
|
+
const byRequest = list.findIndex((entry, candidateIndex) =>
|
|
438
|
+
!used.has(candidateIndex) && sameRequest(descriptorValue, entry),
|
|
439
|
+
);
|
|
440
|
+
if (byRequest >= 0) {
|
|
441
|
+
used.add(byRequest);
|
|
442
|
+
return list[byRequest];
|
|
443
|
+
}
|
|
444
|
+
if (list[index] !== undefined && !used.has(index)) {
|
|
445
|
+
used.add(index);
|
|
446
|
+
return list[index];
|
|
447
|
+
}
|
|
448
|
+
return undefined;
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function responseError(envelope) {
|
|
453
|
+
if (!envelope) return "Unresolved: response missing";
|
|
454
|
+
const status = statusOf(envelope);
|
|
455
|
+
if (status === 429) return `Unresolved: rate limited — retry after ${retryAfter(envelope)}`;
|
|
456
|
+
if (status >= 400) {
|
|
457
|
+
const response = payloadOf(envelope);
|
|
458
|
+
const detail = array(response?.lines).join(" ");
|
|
459
|
+
if (/package not found/i.test(detail)) return UNRESOLVED_PACKAGE;
|
|
460
|
+
return `Unresolved: calculate request failed (${status})`;
|
|
461
|
+
}
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function consumeEchoes(requestedLines, responseLines) {
|
|
466
|
+
const used = new Set();
|
|
467
|
+
return requestedLines.map((requested) => {
|
|
468
|
+
const index = responseLines.findIndex((line, candidateIndex) => {
|
|
469
|
+
if (used.has(candidateIndex)) return false;
|
|
470
|
+
return String(line?.package_id) === String(requested?.package_id)
|
|
471
|
+
&& Number(line?.quantity ?? 1) === Number(requested?.quantity ?? 1);
|
|
472
|
+
});
|
|
473
|
+
if (index < 0) return { requested, response: null };
|
|
474
|
+
used.add(index);
|
|
475
|
+
return { requested, response: responseLines[index] };
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function recurringAnnotation(recurrence) {
|
|
480
|
+
if ((recurrence?.state !== PricingState.Exact && recurrence?.state !== PricingState.Stale)
|
|
481
|
+
|| recurrence?.amount?.state !== recurrence.state
|
|
482
|
+
|| !present(recurrence?.amount?.value)
|
|
483
|
+
|| !present(recurrence?.interval_count)
|
|
484
|
+
|| !present(recurrence?.interval)) return null;
|
|
485
|
+
const count = Number(recurrence.interval_count);
|
|
486
|
+
const unit = `${recurrence.interval}${count === 1 ? "" : "s"}`;
|
|
487
|
+
return `then $${recurrence.amount.value} / ${recurrence.interval_count} ${unit}`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function recurrenceFact(pkg, state, quantityValue) {
|
|
491
|
+
if (!pkg?.is_recurring) return null;
|
|
492
|
+
if (!present(pkg?.price_recurring) || !present(pkg?.interval_count) || !present(pkg?.interval)) {
|
|
493
|
+
return unresolvedFact(UNRESOLVED_INVALID_RECURRENCE);
|
|
494
|
+
}
|
|
495
|
+
const quantity = positiveInteger(quantityValue);
|
|
496
|
+
const intervalCount = positiveInteger(pkg.interval_count);
|
|
497
|
+
const unitCents = moneyToCents(String(pkg.price_recurring));
|
|
498
|
+
const totalCents = unitCents === null || quantity === null ? null : unitCents * quantity;
|
|
499
|
+
const interval = String(pkg.interval).trim();
|
|
500
|
+
if ((state !== PricingState.Exact && state !== PricingState.Stale)
|
|
501
|
+
|| intervalCount === null
|
|
502
|
+
|| unitCents === null
|
|
503
|
+
|| unitCents < 0
|
|
504
|
+
|| !Number.isSafeInteger(totalCents)
|
|
505
|
+
|| !interval) {
|
|
506
|
+
return unresolvedFact(UNRESOLVED_INVALID_RECURRENCE);
|
|
507
|
+
}
|
|
508
|
+
return {
|
|
509
|
+
state,
|
|
510
|
+
amount: moneyFact(centsToUsd(totalCents), state),
|
|
511
|
+
interval_count: intervalCount,
|
|
512
|
+
interval,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function normalizeLine(echo, state, packages) {
|
|
517
|
+
const pkg = packages.find((candidate) => String(candidate?.ref_id) === String(echo.requested?.package_id));
|
|
518
|
+
const recurrence = recurrenceFact(pkg, state, echo.requested?.quantity);
|
|
519
|
+
const recurrenceUnresolved = recurrence?.state === PricingState.Unresolved;
|
|
520
|
+
const rowState = recurrenceUnresolved ? PricingState.Unresolved : state;
|
|
521
|
+
const rowReason = recurrenceUnresolved ? recurrence.reason || UNRESOLVED_INVALID_RECURRENCE : null;
|
|
522
|
+
const common = {
|
|
523
|
+
package_id: echo.requested?.package_id,
|
|
524
|
+
quantity: echo.requested?.quantity,
|
|
525
|
+
...(echo.requested?.is_upsell ? { is_upsell: true } : {}),
|
|
526
|
+
name: pkg?.name ?? null,
|
|
527
|
+
};
|
|
528
|
+
if (!echo.response) return { ...common, state: PricingState.Unresolved, reason: UNRESOLVED_PACKAGE };
|
|
529
|
+
|
|
530
|
+
const line = echo.response;
|
|
531
|
+
return {
|
|
532
|
+
...common,
|
|
533
|
+
state: rowState,
|
|
534
|
+
...(rowReason ? { reason: rowReason } : {}),
|
|
535
|
+
recurring_annotation: recurringAnnotation(recurrence),
|
|
536
|
+
...(recurrence ? { recurrence } : {}),
|
|
537
|
+
base: moneyFact(line.subtotal ?? line.original_package_price, state),
|
|
538
|
+
applied_offers: array(line.discounts).map((discount) => ({
|
|
539
|
+
offer_id: discount.offer_id ?? null,
|
|
540
|
+
name: discount.name ?? null,
|
|
541
|
+
description: discount.description ?? null,
|
|
542
|
+
amount: moneyFact(discount.amount, state),
|
|
543
|
+
})),
|
|
544
|
+
final: moneyFact(line.total, state),
|
|
545
|
+
savings: moneyFact(line.total_discount, state),
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function normalizePlannedRow(line, reason, packages) {
|
|
550
|
+
const pkg = packages.find((candidate) => String(candidate?.ref_id) === String(line?.package_id));
|
|
551
|
+
return {
|
|
552
|
+
package_id: line?.package_id ?? null,
|
|
553
|
+
quantity: line?.quantity ?? null,
|
|
554
|
+
...(line?.is_upsell ? { is_upsell: true } : {}),
|
|
555
|
+
name: pkg?.name ?? null,
|
|
556
|
+
state: PricingState.Unresolved,
|
|
557
|
+
reason,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function normalizeShipping(response, state) {
|
|
562
|
+
const shipping = response?.shipping_method;
|
|
563
|
+
if (!shipping) {
|
|
564
|
+
return {
|
|
565
|
+
present: false,
|
|
566
|
+
state,
|
|
567
|
+
original_price: moneyFact("0.00", state),
|
|
568
|
+
price: moneyFact("0.00", state),
|
|
569
|
+
discounts: [],
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
return {
|
|
573
|
+
present: true,
|
|
574
|
+
state,
|
|
575
|
+
id: shipping.id ?? null,
|
|
576
|
+
name: shipping.name ?? null,
|
|
577
|
+
code: shipping.code ?? null,
|
|
578
|
+
original_price: moneyFact(shipping.original_price ?? "0.00", state),
|
|
579
|
+
price: moneyFact(shipping.price ?? "0.00", state),
|
|
580
|
+
discounts: array(shipping.discounts).map((discount) => ({
|
|
581
|
+
offer_id: discount.offer_id ?? null,
|
|
582
|
+
name: discount.name ?? null,
|
|
583
|
+
amount: moneyFact(discount.amount, state),
|
|
584
|
+
})),
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function mergePackageSources(catalogPackages, pageRows) {
|
|
589
|
+
const merged = new Map();
|
|
590
|
+
array(catalogPackages).forEach((pkg) => {
|
|
591
|
+
if (pkg?.ref_id === undefined || pkg?.ref_id === null) return;
|
|
592
|
+
merged.set(String(pkg.ref_id), { ...pkg });
|
|
593
|
+
});
|
|
594
|
+
array(pageRows).forEach((row) => {
|
|
595
|
+
if (row?.ref_id === undefined || row?.ref_id === null) return;
|
|
596
|
+
const key = String(row.ref_id);
|
|
597
|
+
const base = merged.get(key) || {};
|
|
598
|
+
const overrides = {};
|
|
599
|
+
Object.entries(row).forEach(([field, value]) => {
|
|
600
|
+
if (value !== undefined && value !== null && value !== "") overrides[field] = value;
|
|
601
|
+
});
|
|
602
|
+
merged.set(key, { ...base, ...overrides });
|
|
603
|
+
});
|
|
604
|
+
return [...merged.values()];
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function normalizeScenario(descriptorValue, envelope, catalogPackages, meta) {
|
|
608
|
+
// Authored page rows carry recurring cadence fields (is_recurring,
|
|
609
|
+
// price_recurring, interval, interval_count) even when no campaign
|
|
610
|
+
// catalog is available (the viewer only has the map document). Merge
|
|
611
|
+
// per ref_id: catalog is the base, row fields override only where the
|
|
612
|
+
// row actually carries a value.
|
|
613
|
+
const packages = mergePackageSources(catalogPackages, descriptorValue?.context?.page_rows);
|
|
614
|
+
if (descriptorValue?.unresolved) {
|
|
615
|
+
const reason = descriptorValue.reason || "Unresolved: invalid calculate scenario";
|
|
616
|
+
return {
|
|
617
|
+
state: PricingState.Unresolved,
|
|
618
|
+
reason,
|
|
619
|
+
rows: array(descriptorValue?.context?.planned_rows)
|
|
620
|
+
.map((line) => normalizePlannedRow(line, reason, packages)),
|
|
621
|
+
descriptor: descriptorValue,
|
|
622
|
+
subtotal: unresolvedFact(reason),
|
|
623
|
+
total_discount: unresolvedFact(reason),
|
|
624
|
+
total: unresolvedFact(reason),
|
|
625
|
+
shipping: { present: false, state: PricingState.Unresolved, reason },
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
const error = responseError(envelope);
|
|
629
|
+
if (error) return { state: PricingState.Unresolved, reason: error, rows: [], descriptor: descriptorValue };
|
|
630
|
+
|
|
631
|
+
const response = payloadOf(envelope) || {};
|
|
632
|
+
const resultMeta = {
|
|
633
|
+
...descriptorValue.context,
|
|
634
|
+
...(envelope?.meta || {}),
|
|
635
|
+
spec_hash: envelope?.spec_hash ?? envelope?.meta?.spec_hash ?? descriptorValue.context?.spec_hash,
|
|
636
|
+
catalog_imported_at: envelope?.catalog_imported_at
|
|
637
|
+
?? envelope?.meta?.catalog_imported_at
|
|
638
|
+
?? descriptorValue.context?.catalog_imported_at,
|
|
639
|
+
calculated_at: envelope?.calculated_at ?? envelope?.meta?.calculated_at ?? meta?.calculated_at,
|
|
640
|
+
};
|
|
641
|
+
const state = deriveState(resultMeta, meta);
|
|
642
|
+
const echoes = consumeEchoes(array(descriptorValue?.body?.lines), array(response?.lines));
|
|
643
|
+
const rows = echoes.map((echo) => normalizeLine(echo, state, packages));
|
|
644
|
+
const unresolvedRow = rows.find((row) => row.state === PricingState.Unresolved);
|
|
645
|
+
const missing = Boolean(unresolvedRow);
|
|
646
|
+
const unresolvedReason = unresolvedRow?.reason || UNRESOLVED_PACKAGE;
|
|
647
|
+
const scenarioState = missing ? PricingState.Unresolved : state;
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
state: scenarioState,
|
|
651
|
+
...(missing ? { reason: unresolvedReason } : {}),
|
|
652
|
+
descriptor: descriptorValue,
|
|
653
|
+
response,
|
|
654
|
+
rows,
|
|
655
|
+
subtotal: missing ? unresolvedFact(unresolvedReason) : moneyFact(response.subtotal, state),
|
|
656
|
+
total_discount: missing ? unresolvedFact(unresolvedReason) : moneyFact(response.total_discount, state),
|
|
657
|
+
total: missing ? unresolvedFact(unresolvedReason) : moneyFact(response.total, state),
|
|
658
|
+
currency: response.currency ?? null,
|
|
659
|
+
shipping: missing
|
|
660
|
+
? { present: false, state: PricingState.Unresolved, reason: unresolvedReason }
|
|
661
|
+
: present(descriptorValue?.body?.shipping_method)
|
|
662
|
+
? normalizeShipping(response, state)
|
|
663
|
+
: { present: false, state },
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function worstState(states) {
|
|
668
|
+
if (states.includes(PricingState.Unresolved)) return PricingState.Unresolved;
|
|
669
|
+
if (states.includes(PricingState.Stale)) return PricingState.Stale;
|
|
670
|
+
return PricingState.Exact;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function attributableDiscount(response, code) {
|
|
674
|
+
const upperCode = String(code || "").toUpperCase();
|
|
675
|
+
return array(response?.voucher_discounts).some((discount) => {
|
|
676
|
+
const identifiers = [
|
|
677
|
+
discount?.code,
|
|
678
|
+
discount?.voucher_code,
|
|
679
|
+
discount?.coupon_code,
|
|
680
|
+
discount?.offer_code,
|
|
681
|
+
discount?.name,
|
|
682
|
+
].filter(present).map((value) => String(value).toUpperCase());
|
|
683
|
+
if (identifiers.includes(upperCode)) return true;
|
|
684
|
+
|
|
685
|
+
// Code-aware boundary match: the code must appear in the description as a
|
|
686
|
+
// whole unit bounded by start/end or non-alphanumerics. Handles punctuated
|
|
687
|
+
// codes (SAVE-10, alone or inside "SAVE-10 PROMO") without letting SAVE
|
|
688
|
+
// match SAVE10.
|
|
689
|
+
if (!upperCode) return false;
|
|
690
|
+
const description = String(discount?.description || "").toUpperCase();
|
|
691
|
+
const escaped = upperCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
692
|
+
return new RegExp(`(^|[^A-Z0-9])${escaped}([^A-Z0-9]|$)`).test(description);
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function totalCents(scenario) {
|
|
697
|
+
return scenario?.total?.state === PricingState.Unresolved ? null : moneyToCents(scenario?.total?.value);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function deltaFact(before, after, direction = "impact") {
|
|
701
|
+
if (!before || !after || before.state === PricingState.Unresolved || after.state === PricingState.Unresolved) {
|
|
702
|
+
return unresolvedFact(before?.reason || after?.reason || "Unresolved: comparison unavailable");
|
|
703
|
+
}
|
|
704
|
+
const beforeCents = totalCents(before);
|
|
705
|
+
const afterCents = totalCents(after);
|
|
706
|
+
if (beforeCents === null || afterCents === null) return unresolvedFact("Unresolved: invalid server money value");
|
|
707
|
+
const cents = direction === "savings" ? beforeCents - afterCents : afterCents - beforeCents;
|
|
708
|
+
return moneyFact(signedCents(cents), worstState([before.state, after.state]));
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function knownCode(code, context, catalog) {
|
|
712
|
+
const upper = String(code || "").toUpperCase();
|
|
713
|
+
return array(context?.known_offer_codes).some((candidate) => String(candidate).toUpperCase() === upper)
|
|
714
|
+
|| array(catalog?.offers).some((offer) => String(offer?.code || "").toUpperCase() === upper);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function pageContext(scenarios) {
|
|
718
|
+
return scenarios[0]?.descriptor?.context || {};
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function roleScenarios(scenarios, role) {
|
|
722
|
+
return scenarios.filter((scenario) => scenario?.descriptor?.context?.role === role);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function scenarioByIndexedRole(scenarios, role, field, index) {
|
|
726
|
+
return scenarios.find((scenario) => scenario?.descriptor?.context?.role === role
|
|
727
|
+
&& Number(scenario?.descriptor?.context?.[field]) === Number(index));
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function representativeForPage(scenarios, catalog) {
|
|
731
|
+
const plain = roleScenarios(scenarios, "representative")[0];
|
|
732
|
+
if (plain) return { scenario: plain, offers: [] };
|
|
733
|
+
|
|
734
|
+
const without = roleScenarios(scenarios, "representative-without")[0];
|
|
735
|
+
const withVoucher = roleScenarios(scenarios, "representative-with")[0];
|
|
736
|
+
if (!without || !withVoucher) {
|
|
737
|
+
const missing = withVoucher || without || { state: PricingState.Unresolved, reason: "Unresolved: response missing" };
|
|
738
|
+
return { scenario: missing, offers: [] };
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const codes = array(withVoucher?.descriptor?.body?.vouchers);
|
|
742
|
+
const unresolvedPairHalf = [without, withVoucher]
|
|
743
|
+
.find((scenario) => scenario.state === PricingState.Unresolved);
|
|
744
|
+
if (unresolvedPairHalf) {
|
|
745
|
+
const reason = unresolvedPairHalf.reason
|
|
746
|
+
|| unresolvedPairHalf.total?.reason
|
|
747
|
+
|| "Unresolved: comparison unavailable";
|
|
748
|
+
return {
|
|
749
|
+
scenario: unresolvedPairHalf,
|
|
750
|
+
offers: codes.map((code) => ({
|
|
751
|
+
code,
|
|
752
|
+
calculation_evidence: CALCULATED_PAIR_EVIDENCE,
|
|
753
|
+
state: PricingState.Unresolved,
|
|
754
|
+
status: "Unresolved",
|
|
755
|
+
activation: `Voucher ${code} · carried by page · server application unresolved in this scenario`,
|
|
756
|
+
reason,
|
|
757
|
+
})),
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
const totalsDiffer = totalCents(without) !== null
|
|
761
|
+
&& totalCents(withVoucher) !== null
|
|
762
|
+
&& totalCents(without) !== totalCents(withVoucher);
|
|
763
|
+
const activations = codes.map((code) => {
|
|
764
|
+
if (!knownCode(code, withVoucher.descriptor.context, catalog)) {
|
|
765
|
+
return {
|
|
766
|
+
code,
|
|
767
|
+
calculation_evidence: CALCULATED_PAIR_EVIDENCE,
|
|
768
|
+
state: PricingState.Unresolved,
|
|
769
|
+
status: "Unresolved",
|
|
770
|
+
activation: `Voucher ${code} · carried by page · server application unresolved in this scenario`,
|
|
771
|
+
reason: UNRESOLVED_STORE_COUPON,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
const applied = totalsDiffer && attributableDiscount(withVoucher.response, code);
|
|
775
|
+
const state = worstState([without.state, withVoucher.state]);
|
|
776
|
+
return {
|
|
777
|
+
code,
|
|
778
|
+
calculation_evidence: CALCULATED_PAIR_EVIDENCE,
|
|
779
|
+
state,
|
|
780
|
+
status: applied ? "Applied" : "Not applied",
|
|
781
|
+
activation: applied
|
|
782
|
+
? `Voucher ${code} · carried by page · applied by server in this scenario`
|
|
783
|
+
: `Voucher ${code} · carried by page · not applied by server in this scenario`,
|
|
784
|
+
...(applied ? { delta: deltaFact(without, withVoucher, "impact") } : {}),
|
|
785
|
+
};
|
|
786
|
+
});
|
|
787
|
+
const applied = activations.some((activation) => activation.status === "Applied");
|
|
788
|
+
return { scenario: applied ? withVoucher : without, offers: activations };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function automaticOfferActivations(context, representative, catalog) {
|
|
792
|
+
const response = representative?.response || {};
|
|
793
|
+
const allDiscounts = [
|
|
794
|
+
...array(response.offer_discounts),
|
|
795
|
+
...array(response.lines).flatMap((line) => array(line?.discounts)),
|
|
796
|
+
...array(response.shipping_method?.discounts),
|
|
797
|
+
];
|
|
798
|
+
|
|
799
|
+
return array(context?.attached_offers).filter((offer) => !present(offer?.code)).map((offer) => {
|
|
800
|
+
if (context.page_type === "upsell" || context.page_type === "downsell") {
|
|
801
|
+
return {
|
|
802
|
+
offer_ref_id: offerIdentity(offer) || null,
|
|
803
|
+
name: offer?.name ?? null,
|
|
804
|
+
state: representative?.state || PricingState.Exact,
|
|
805
|
+
status: "Not applied",
|
|
806
|
+
activation: "Attached, not applied in this scenario",
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
const applied = allDiscounts.some((discount) =>
|
|
810
|
+
(present(offer?.name) && String(discount?.name) === String(offer.name))
|
|
811
|
+
|| (present(offer?.code) && String(discount?.description) === String(offer.code)),
|
|
812
|
+
);
|
|
813
|
+
return {
|
|
814
|
+
offer_ref_id: offerIdentity(offer) || null,
|
|
815
|
+
name: offer?.name ?? null,
|
|
816
|
+
state: representative?.state || PricingState.Exact,
|
|
817
|
+
status: applied ? "Applied" : "Not applied",
|
|
818
|
+
activation: applied ? "Automatic site offer · applied by server" : "Attached, not applied in this scenario",
|
|
819
|
+
};
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function unattachedVoucherActivations(context, representedCodes) {
|
|
824
|
+
const represented = new Set(representedCodes.map(String));
|
|
825
|
+
return array(context?.attached_offers)
|
|
826
|
+
.filter((offer) => present(offer?.code) && !represented.has(String(offer.code)))
|
|
827
|
+
.map((offer) => ({
|
|
828
|
+
offer_ref_id: offerIdentity(offer) || null,
|
|
829
|
+
name: offer?.name ?? null,
|
|
830
|
+
code: String(offer.code),
|
|
831
|
+
state: PricingState.Exact,
|
|
832
|
+
status: "Not applied",
|
|
833
|
+
activation: "Attached, not applied in this scenario",
|
|
834
|
+
}));
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function surfaceActivations(scenarios, catalog) {
|
|
838
|
+
const withScenarios = roleScenarios(scenarios, "surface-with");
|
|
839
|
+
return withScenarios.map((withScenario) => {
|
|
840
|
+
const context = withScenario.descriptor.context;
|
|
841
|
+
const without = scenarios.find((candidate) =>
|
|
842
|
+
candidate.descriptor.context.role === "surface-without"
|
|
843
|
+
&& candidate.descriptor.context.surface === context.surface
|
|
844
|
+
&& String(candidate.descriptor.context.offer_code) === String(context.offer_code),
|
|
845
|
+
);
|
|
846
|
+
const code = context.offer_code;
|
|
847
|
+
const source = context.surface === "exit_intent" ? "Exit intent" : "Promo code";
|
|
848
|
+
if (!knownCode(code, context, catalog)) {
|
|
849
|
+
return {
|
|
850
|
+
code,
|
|
851
|
+
source: context.surface,
|
|
852
|
+
state: PricingState.Unresolved,
|
|
853
|
+
status: "Unresolved",
|
|
854
|
+
activation: `${source} ${code} · if code entered`,
|
|
855
|
+
reason: UNRESOLVED_STORE_COUPON,
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
if (!without || withScenario.state === PricingState.Unresolved || without.state === PricingState.Unresolved) {
|
|
859
|
+
return {
|
|
860
|
+
code,
|
|
861
|
+
source: context.surface,
|
|
862
|
+
state: PricingState.Unresolved,
|
|
863
|
+
status: "Unresolved",
|
|
864
|
+
activation: `${source} ${code} · if code entered`,
|
|
865
|
+
reason: without?.reason || withScenario.reason || "Unresolved: comparison unavailable",
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
const totalsDiffer = totalCents(without) !== null
|
|
869
|
+
&& totalCents(withScenario) !== null
|
|
870
|
+
&& totalCents(without) !== totalCents(withScenario);
|
|
871
|
+
const applied = totalsDiffer && attributableDiscount(withScenario.response, code);
|
|
872
|
+
const state = worstState([without.state, withScenario.state]);
|
|
873
|
+
return {
|
|
874
|
+
code,
|
|
875
|
+
source: context.surface,
|
|
876
|
+
state,
|
|
877
|
+
status: applied ? "Applied" : "Not applied",
|
|
878
|
+
activation: `${source} ${code} · if code entered`,
|
|
879
|
+
...(applied ? { delta: deltaFact(without, withScenario, "impact") } : {}),
|
|
880
|
+
};
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function bumpDeltas(scenarios, rows) {
|
|
885
|
+
const bumpIndexes = [...new Set(roleScenarios(scenarios, "bump-with").map((scenario) => scenario.descriptor.context.bump_index))];
|
|
886
|
+
const bumpRows = rows.filter((row) => isBumpRow(row));
|
|
887
|
+
return bumpIndexes.map((index) => {
|
|
888
|
+
const without = scenarioByIndexedRole(scenarios, "bump-without", "bump_index", index);
|
|
889
|
+
const withBump = scenarioByIndexedRole(scenarios, "bump-with", "bump_index", index);
|
|
890
|
+
const row = bumpRows[Number(index)];
|
|
891
|
+
const delta = deltaFact(without, withBump, "impact");
|
|
892
|
+
const signedDelta = delta.value === "0.00" ? { ...delta, value: "+0.00" } : delta;
|
|
893
|
+
return {
|
|
894
|
+
row_index: row?.row_index ?? null,
|
|
895
|
+
package_id: row?.package_id ?? withBump?.descriptor?.body?.lines?.at(-1)?.package_id ?? null,
|
|
896
|
+
name: row?.name ?? null,
|
|
897
|
+
condition: "if taken",
|
|
898
|
+
...signedDelta,
|
|
899
|
+
};
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function pageRows(scenarios) {
|
|
904
|
+
const details = roleScenarios(scenarios, "row").sort((a, b) =>
|
|
905
|
+
Number(a.descriptor.context.row_index) - Number(b.descriptor.context.row_index),
|
|
906
|
+
);
|
|
907
|
+
const source = details.length ? details : [representativeForPage(scenarios, {}).scenario];
|
|
908
|
+
return source.flatMap((scenario) => array(scenario?.rows).map((row, offset) => ({
|
|
909
|
+
...row,
|
|
910
|
+
row_index: scenario?.descriptor?.context?.row_index ?? offset,
|
|
911
|
+
})));
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function makePage(scenarios, catalog) {
|
|
915
|
+
const context = pageContext(scenarios);
|
|
916
|
+
const representative = representativeForPage(scenarios, catalog);
|
|
917
|
+
const rows = pageRows(scenarios);
|
|
918
|
+
const carriedCodes = representative.offers.map((offer) => offer.code);
|
|
919
|
+
const surfaceCodes = roleScenarios(scenarios, "surface-with").map((scenario) => scenario.descriptor.context.offer_code);
|
|
920
|
+
const offers = [
|
|
921
|
+
...representative.offers,
|
|
922
|
+
...automaticOfferActivations(context, representative.scenario, catalog),
|
|
923
|
+
...unattachedVoucherActivations(context, [...carriedCodes, ...surfaceCodes]),
|
|
924
|
+
...surfaceActivations(scenarios, catalog),
|
|
925
|
+
];
|
|
926
|
+
const calculatedBumps = bumpDeltas(scenarios, rows);
|
|
927
|
+
const pageReason = representative.scenario?.state === PricingState.Unresolved
|
|
928
|
+
? representative.scenario.reason || representative.scenario?.total?.reason
|
|
929
|
+
: offers.find((offer) => offer.reason === UNRESOLVED_STORE_COUPON)?.reason;
|
|
930
|
+
const visibleRows = pageReason === UNRESOLVED_STORE_COUPON
|
|
931
|
+
? rows.map((row) => ({
|
|
932
|
+
row_index: row.row_index,
|
|
933
|
+
package_id: row.package_id,
|
|
934
|
+
quantity: row.quantity,
|
|
935
|
+
...(row.is_upsell ? { is_upsell: true } : {}),
|
|
936
|
+
name: row.name,
|
|
937
|
+
state: PricingState.Unresolved,
|
|
938
|
+
reason: pageReason,
|
|
939
|
+
}))
|
|
940
|
+
: rows;
|
|
941
|
+
const representativeTotal = pageReason
|
|
942
|
+
? unresolvedFact(pageReason)
|
|
943
|
+
: representative.scenario?.total
|
|
944
|
+
|| unresolvedFact("Unresolved: representative scenario unavailable");
|
|
945
|
+
const bumps = pageReason
|
|
946
|
+
? calculatedBumps.map((bump) => ({
|
|
947
|
+
row_index: bump.row_index,
|
|
948
|
+
package_id: bump.package_id,
|
|
949
|
+
name: bump.name,
|
|
950
|
+
condition: bump.condition,
|
|
951
|
+
...unresolvedFact(pageReason),
|
|
952
|
+
}))
|
|
953
|
+
: calculatedBumps;
|
|
954
|
+
const states = [representativeTotal.state, ...visibleRows.map((row) => row.state), ...offers.map((offer) => offer.state), ...bumps.map((bump) => bump.state)].filter(Boolean);
|
|
955
|
+
|
|
956
|
+
return {
|
|
957
|
+
page_id: context.page_id,
|
|
958
|
+
page_type: context.page_type,
|
|
959
|
+
page_label: context.page_label,
|
|
960
|
+
page_order: context.page_order,
|
|
961
|
+
funnel_id: context.funnel_id,
|
|
962
|
+
decline_condition: context.decline_condition,
|
|
963
|
+
state: worstState(states),
|
|
964
|
+
...(pageReason ? { reason: pageReason } : {}),
|
|
965
|
+
rows: visibleRows,
|
|
966
|
+
offers,
|
|
967
|
+
representative_total: representativeTotal,
|
|
968
|
+
shipping: pageReason
|
|
969
|
+
? { present: false, state: PricingState.Unresolved, reason: pageReason }
|
|
970
|
+
: representative.scenario?.shipping || {
|
|
971
|
+
present: false,
|
|
972
|
+
state: PricingState.Unresolved,
|
|
973
|
+
reason: "Unresolved: representative scenario unavailable",
|
|
974
|
+
},
|
|
975
|
+
bumps,
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function acceptDelta(page) {
|
|
980
|
+
const total = page.representative_total;
|
|
981
|
+
if (total?.state === PricingState.Unresolved) return unresolvedFact(total.reason);
|
|
982
|
+
const cents = moneyToCents(total.value);
|
|
983
|
+
if (cents === null) return unresolvedFact("Unresolved: invalid server money value");
|
|
984
|
+
return moneyFact(signedCents(cents), total.state);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function journeySummary(pages) {
|
|
988
|
+
const checkout = pages.find((page) => page.page_type === "checkout");
|
|
989
|
+
const checkoutTotal = checkout?.representative_total
|
|
990
|
+
|| unresolvedFact("Unresolved: checkout scenario unavailable");
|
|
991
|
+
const bumps = pages.flatMap((page) => page.bumps.map((bump) => ({ ...bump, page_id: page.page_id })));
|
|
992
|
+
const accepts = pages.filter((page) => page.page_type === "upsell" || page.page_type === "downsell").map((page) => ({
|
|
993
|
+
page_id: page.page_id,
|
|
994
|
+
page_type: page.page_type,
|
|
995
|
+
page_label: page.page_label,
|
|
996
|
+
condition: page.page_type === "downsell" ? page.decline_condition : "if accepted",
|
|
997
|
+
...acceptDelta(page),
|
|
998
|
+
}));
|
|
999
|
+
|
|
1000
|
+
const rangeParts = [checkoutTotal, ...bumps, ...accepts.filter((entry) => entry.page_type === "upsell")];
|
|
1001
|
+
let range;
|
|
1002
|
+
const unresolvedPage = pages.find((page) => page.representative_total?.state === PricingState.Unresolved);
|
|
1003
|
+
if (unresolvedPage) {
|
|
1004
|
+
range = unresolvedFact(
|
|
1005
|
+
unresolvedPage.representative_total.reason || "Unresolved: order range has unresolved page totals",
|
|
1006
|
+
);
|
|
1007
|
+
} else if (rangeParts.some((part) => part.state === PricingState.Unresolved)) {
|
|
1008
|
+
range = unresolvedFact("Unresolved: order range has unresolved components");
|
|
1009
|
+
} else {
|
|
1010
|
+
const minimum = moneyToCents(checkoutTotal.value);
|
|
1011
|
+
const extras = [...bumps, ...accepts.filter((entry) => entry.page_type === "upsell")]
|
|
1012
|
+
.map((entry) => moneyToCents(entry.value));
|
|
1013
|
+
if (minimum === null || extras.some((entry) => entry === null)) {
|
|
1014
|
+
range = unresolvedFact("Unresolved: invalid server money value");
|
|
1015
|
+
} else {
|
|
1016
|
+
const maximum = minimum + extras.reduce((sum, cents) => sum + cents, 0);
|
|
1017
|
+
range = {
|
|
1018
|
+
state: worstState(rangeParts.map((part) => part.state)),
|
|
1019
|
+
min: centsToUsd(minimum),
|
|
1020
|
+
max: centsToUsd(maximum),
|
|
1021
|
+
label: CALC_LABEL,
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
return {
|
|
1027
|
+
representative_checkout_total: checkoutTotal,
|
|
1028
|
+
bumps,
|
|
1029
|
+
accepts,
|
|
1030
|
+
min_max_order_range: range,
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const unresolvedMoneyKey = /(?:price|amount|delta|range|total|subtotal|savings|recurring_annotation|^min$|^max$|^value$)/i;
|
|
1035
|
+
const numericMoneyString = /^[+-]?\d+(?:\.\d{1,2})?$/;
|
|
1036
|
+
const annotatedMoneyString = /\$[+-]?\d+(?:\.\d{1,2})?/;
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Assert that every Unresolved fact is number-free while allowing exact child
|
|
1040
|
+
* facts (for example, surviving single-line rows on an unresolved page).
|
|
1041
|
+
*/
|
|
1042
|
+
export function assertUnresolvedFactsHaveNoNumbers(value, path = "journey") {
|
|
1043
|
+
if (!value || typeof value !== "object") return value;
|
|
1044
|
+
if (value.state === PricingState.Unresolved) {
|
|
1045
|
+
Object.entries(value).forEach(([key, child]) => {
|
|
1046
|
+
const numeric = typeof child === "number"
|
|
1047
|
+
|| (typeof child === "string" && (numericMoneyString.test(child)
|
|
1048
|
+
|| (key === "recurring_annotation" && annotatedMoneyString.test(child))));
|
|
1049
|
+
if (unresolvedMoneyKey.test(key) && numeric) {
|
|
1050
|
+
throw new TypeError(`Unresolved fact carries numeric field ${path}.${key}`);
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
Object.entries(value).forEach(([key, child]) => {
|
|
1055
|
+
if (Array.isArray(child)) {
|
|
1056
|
+
child.forEach((entry, index) => assertUnresolvedFactsHaveNoNumbers(entry, `${path}.${key}[${index}]`));
|
|
1057
|
+
} else {
|
|
1058
|
+
assertUnresolvedFactsHaveNoNumbers(child, `${path}.${key}`);
|
|
1059
|
+
}
|
|
1060
|
+
});
|
|
1061
|
+
return value;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Normalize executed plan results into the transport-independent view model.
|
|
1066
|
+
* Responses may be keyed by descriptor id or supplied as an aligned/captured
|
|
1067
|
+
* envelope array: { id?, request?, status, response, headers?, meta? }.
|
|
1068
|
+
*/
|
|
1069
|
+
export function normalizeJourney(plan, responses, catalog = {}, meta = {}) {
|
|
1070
|
+
const descriptors = array(plan);
|
|
1071
|
+
const packages = array(catalog?.packages);
|
|
1072
|
+
const lookup = responseLookup(descriptors, responses);
|
|
1073
|
+
const scenarios = descriptors.map((descriptorValue, index) => normalizeScenario(
|
|
1074
|
+
descriptorValue,
|
|
1075
|
+
descriptorValue?.unresolved ? undefined : lookup(descriptorValue, index),
|
|
1076
|
+
packages,
|
|
1077
|
+
meta,
|
|
1078
|
+
));
|
|
1079
|
+
|
|
1080
|
+
const grouped = new Map();
|
|
1081
|
+
scenarios.forEach((scenario) => {
|
|
1082
|
+
const id = String(scenario.descriptor?.context?.page_id ?? "page");
|
|
1083
|
+
if (!grouped.has(id)) grouped.set(id, []);
|
|
1084
|
+
grouped.get(id).push(scenario);
|
|
1085
|
+
});
|
|
1086
|
+
const pages = [...grouped.values()].map((entries) => makePage(entries, catalog)).sort((a, b) => a.page_order - b.page_order);
|
|
1087
|
+
|
|
1088
|
+
const warnings = [];
|
|
1089
|
+
scenarios.forEach((scenario) => {
|
|
1090
|
+
const requested = scenario.descriptor?.body?.currency;
|
|
1091
|
+
const echoed = scenario.currency;
|
|
1092
|
+
if (present(requested) && present(echoed) && String(requested) !== String(echoed)) {
|
|
1093
|
+
const key = `${requested}:${echoed}`;
|
|
1094
|
+
if (!warnings.some((warning) => warning.key === key)) {
|
|
1095
|
+
warnings.push({
|
|
1096
|
+
key,
|
|
1097
|
+
type: "currency_mismatch",
|
|
1098
|
+
requested: String(requested),
|
|
1099
|
+
actual: String(echoed),
|
|
1100
|
+
message: `Requested currency ${requested}; server calculated ${echoed}.`,
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
const journeyState = worstState(pages.map((page) => page.state));
|
|
1107
|
+
const journeyReason = journeyState === PricingState.Unresolved
|
|
1108
|
+
? pages.find((page) => page.state === PricingState.Unresolved)?.reason
|
|
1109
|
+
: null;
|
|
1110
|
+
return assertUnresolvedFactsHaveNoNumbers({
|
|
1111
|
+
state: journeyState,
|
|
1112
|
+
...(journeyReason ? { reason: journeyReason } : {}),
|
|
1113
|
+
label: CALC_LABEL,
|
|
1114
|
+
pages,
|
|
1115
|
+
summary: pages.length ? journeySummary(pages) : null,
|
|
1116
|
+
warnings,
|
|
1117
|
+
meta: { ...meta },
|
|
1118
|
+
});
|
|
1119
|
+
}
|