@wix/himalaya-cli 0.1240.0 → 0.1241.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.
@@ -1,6 +1,8 @@
1
1
  import type { TransactionStore } from "./delivery-contract.js";
2
2
  export * from "./delivery-contract.js";
3
3
  export * from "./delivery.js";
4
+ export * from "./pricing-contract.js";
5
+ export * from "./pricing.js";
4
6
  /** The three store product categories. */
5
7
  export type ProductType = "subscription" | "nonConsumable" | "consumable";
6
8
  export interface Product {
@@ -0,0 +1,81 @@
1
+ import type { Product } from "./index.js";
2
+ export interface CatalogReference {
3
+ kind: string;
4
+ id: string;
5
+ }
6
+ /** Opaque context revision changes whenever a mapping-dependent audience changes. */
7
+ export interface PricingScope {
8
+ appId: string;
9
+ bundleId: string;
10
+ siteId: string;
11
+ contextRevision?: string;
12
+ }
13
+ export interface PricingItem {
14
+ reference: CatalogReference;
15
+ /** Owned/formatted by the commerce caller; used only for an explicit regular route. */
16
+ commerceDisplayPrice?: string;
17
+ }
18
+ export interface PricingInput {
19
+ scope: PricingScope;
20
+ /** Trusted host platform; only "ios" performs Apple lookup. */
21
+ platform: string;
22
+ items: PricingItem[];
23
+ }
24
+ export type PricingMapping = {
25
+ reference: CatalogReference;
26
+ } & ({
27
+ route: "appleIap";
28
+ productId: string;
29
+ mappingRevision: string;
30
+ } | {
31
+ route: "regular";
32
+ } | {
33
+ route: "unavailable";
34
+ });
35
+ /** Internal client port, not an agreed/deployed backend API. Never requires purchase context. */
36
+ export interface PricingMappingProvider {
37
+ resolve(input: {
38
+ scope: PricingScope;
39
+ references: CatalogReference[];
40
+ }): Promise<PricingMapping[]>;
41
+ }
42
+ export type PricingUnavailableReason = "invalidInput" | "mappingUnavailable" | "mappingUnresolved" | "unsupportedPlatform" | "unsupportedProductType" | "priceUnavailable" | "commercePriceUnavailable";
43
+ export type ItemPriceState = {
44
+ reference: CatalogReference;
45
+ } & ({
46
+ status: "loading";
47
+ } | {
48
+ status: "ready";
49
+ source: "appleIap";
50
+ displayPrice: string;
51
+ productId: string;
52
+ mappingRevision: string;
53
+ } | {
54
+ status: "ready";
55
+ source: "regular";
56
+ displayPrice: string;
57
+ } | {
58
+ status: "unavailable";
59
+ reason: PricingUnavailableReason;
60
+ retryable: boolean;
61
+ });
62
+ /** Contains no credentials, transaction proof, entitlement, or purchase authorization. */
63
+ export interface PricingState {
64
+ revision: number;
65
+ scope: PricingScope | null;
66
+ items: ItemPriceState[];
67
+ }
68
+ export interface PricingDependencies {
69
+ mapping: PricingMappingProvider;
70
+ getProducts(productIds: string[]): Promise<Product[]>;
71
+ onState?(state: PricingState): void;
72
+ }
73
+ export interface PricingController {
74
+ /** Fresh lookup; identical in-flight input is shared. Completed prices are never cached. */
75
+ load(input: PricingInput): Promise<void>;
76
+ /** Reloads the last input, joining a matching lookup already in progress. */
77
+ refresh(): Promise<void>;
78
+ getState(): PricingState;
79
+ /** Clears state and suppresses subsequent calls/publications; cannot cancel native I/O. */
80
+ dispose(): void;
81
+ }
@@ -0,0 +1,3 @@
1
+ import type { PricingController, PricingDependencies } from "./pricing-contract.js";
2
+ /** Creates one worker's read-only pricing controller with injected mapping, store lookup, and state publication. */
3
+ export declare function createPricingController(deps: PricingDependencies): PricingController;
@@ -25,7 +25,7 @@ function createPurchaseCoordinator(deps) {
25
25
  let outcome = "idle";
26
26
  let purchasing = false;
27
27
  let signInPrompt = false;
28
- let unavailable = null;
28
+ let unavailable2 = null;
29
29
  let pendingApproval = null;
30
30
  let errorCode = null;
31
31
  let errorFromReconcile = false;
@@ -61,7 +61,7 @@ function createPurchaseCoordinator(deps) {
61
61
  if (disposed || !sessionMatches()) return { phase: "idle", errorCode: null, items: [], unfinishedCount: null, nextWakeAt: null };
62
62
  const snapshot = [...items.values()].map((item) => ({ ...item.state }));
63
63
  const due = snapshot.map((item) => item.nextAttemptAt).filter((at) => at !== null);
64
- return { phase: currentPhase(), errorCode: errorCode ?? unavailable?.code ?? null, items: snapshot, unfinishedCount, nextWakeAt: due.length > 0 ? Math.min(...due) : null };
64
+ return { phase: currentPhase(), errorCode: errorCode ?? unavailable2?.code ?? null, items: snapshot, unfinishedCount, nextWakeAt: due.length > 0 ? Math.min(...due) : null };
65
65
  }
66
66
  function dispose() {
67
67
  disposed = true;
@@ -73,7 +73,7 @@ function createPurchaseCoordinator(deps) {
73
73
  setError(null, false);
74
74
  outcome = "idle";
75
75
  pendingApproval = null;
76
- unavailable = null;
76
+ unavailable2 = null;
77
77
  releaseSignIn();
78
78
  if (session.kind !== "member") {
79
79
  requireSignIn();
@@ -132,7 +132,7 @@ function createPurchaseCoordinator(deps) {
132
132
  }
133
133
  }
134
134
  function clearReconcileProblem() {
135
- if (unavailable?.from === "reconcile") unavailable = null;
135
+ if (unavailable2?.from === "reconcile") unavailable2 = null;
136
136
  if (errorFromReconcile) setError(null, false);
137
137
  }
138
138
  async function runRetry() {
@@ -297,7 +297,7 @@ function createPurchaseCoordinator(deps) {
297
297
  outcome = "idle";
298
298
  purchasing = false;
299
299
  signInPrompt = false;
300
- unavailable = null;
300
+ unavailable2 = null;
301
301
  pendingApproval = null;
302
302
  unfinishedCount = null;
303
303
  setError(null, false);
@@ -355,8 +355,8 @@ function createPurchaseCoordinator(deps) {
355
355
  }
356
356
  function failStore(code, fromReconcile) {
357
357
  setError(code, fromReconcile);
358
- if (UNAVAILABLE_CODES.has(code) && unavailable?.from !== "purchase") {
359
- unavailable = { from: fromReconcile ? "reconcile" : "purchase", code };
358
+ if (UNAVAILABLE_CODES.has(code) && unavailable2?.from !== "purchase") {
359
+ unavailable2 = { from: fromReconcile ? "reconcile" : "purchase", code };
360
360
  }
361
361
  publish();
362
362
  }
@@ -373,9 +373,9 @@ function createPurchaseCoordinator(deps) {
373
373
  }
374
374
  function currentPhase() {
375
375
  if (purchasing) return "purchasing";
376
- if (unavailable?.from === "purchase") return "unavailable";
376
+ if (unavailable2?.from === "purchase") return "unavailable";
377
377
  if (signInPrompt) return "signInRequired";
378
- if (unavailable?.from === "reconcile") return "unavailable";
378
+ if (unavailable2?.from === "reconcile") return "unavailable";
379
379
  return outcome;
380
380
  }
381
381
  function setError(code, fromReconcile) {
@@ -514,6 +514,140 @@ function createSerialQueue() {
514
514
  };
515
515
  }
516
516
 
517
+ // ../../core/sdk/iap/src/pricing.ts
518
+ function createPricingController(deps) {
519
+ let state = { revision: 0, scope: null, items: [] };
520
+ let lastInput;
521
+ let active;
522
+ let disposed = false;
523
+ function load(input) {
524
+ if (disposed) return Promise.resolve();
525
+ const captured = captureInput(input);
526
+ const key = JSON.stringify(captured);
527
+ if (active?.key === key) return active.promise;
528
+ lastInput = captured;
529
+ const revision = state.revision + 1;
530
+ let complete;
531
+ const promise = new Promise((resolve) => {
532
+ complete = resolve;
533
+ });
534
+ active = { key, promise };
535
+ publish({ revision, scope: captured.scope, items: captured.items.map((item) => ({ reference: item.reference, status: "loading" })) });
536
+ void run(captured, revision).finally(complete);
537
+ return promise;
538
+ }
539
+ async function run(input, revision) {
540
+ const current = () => !disposed && state.revision === revision;
541
+ if (!current()) return;
542
+ const items = await resolvePrices(input, deps, current);
543
+ if (!current()) return;
544
+ active = void 0;
545
+ publish({ revision, scope: input.scope, items });
546
+ }
547
+ function publish(next) {
548
+ state = next;
549
+ try {
550
+ deps.onState?.(getState());
551
+ } catch {
552
+ }
553
+ }
554
+ function getState() {
555
+ return {
556
+ ...state,
557
+ scope: state.scope ? { ...state.scope } : null,
558
+ items: state.items.map((item) => ({ ...item, reference: { ...item.reference } }))
559
+ };
560
+ }
561
+ function dispose() {
562
+ disposed = true;
563
+ active = void 0;
564
+ lastInput = void 0;
565
+ state = { revision: state.revision + 1, scope: null, items: [] };
566
+ }
567
+ return { load, refresh: () => lastInput ? load(lastInput) : Promise.resolve(), getState, dispose };
568
+ }
569
+ function captureInput(input) {
570
+ const scope = {
571
+ appId: input.scope.appId,
572
+ bundleId: input.scope.bundleId,
573
+ siteId: input.scope.siteId,
574
+ ...input.scope.contextRevision === void 0 ? {} : { contextRevision: input.scope.contextRevision }
575
+ };
576
+ const items = /* @__PURE__ */ new Map();
577
+ for (const item of input.items) {
578
+ const reference = { kind: item.reference.kind, id: item.reference.id };
579
+ const key = JSON.stringify([reference.kind, reference.id]);
580
+ if (!items.has(key)) items.set(key, { reference, ...item.commerceDisplayPrice === void 0 ? {} : { commerceDisplayPrice: item.commerceDisplayPrice } });
581
+ }
582
+ return { scope, platform: input.platform, items: [...items.values()] };
583
+ }
584
+ async function resolvePrices(input, deps, current) {
585
+ if (input.items.length === 0) return [];
586
+ if (![input.scope.appId, input.scope.bundleId, input.scope.siteId].every(nonempty)) {
587
+ return input.items.map((item) => unavailable(item.reference, "invalidInput"));
588
+ }
589
+ const mappings = await resolveMappings(input, deps);
590
+ if (!current()) return [];
591
+ if (mappings === null) return input.items.map((item) => unavailable(item.reference, "mappingUnavailable"));
592
+ const routes = input.items.map((item) => resolveRoute(item, mappings, input.platform));
593
+ const ids = [...new Set(routes.flatMap((route) => route.status === "loading" ? [route.mapping.productId] : []))];
594
+ const products = ids.length ? await lookupProducts(ids, deps) : [];
595
+ return routes.map((route) => route.status === "loading" ? applePrice(route.mapping, products) : route);
596
+ }
597
+ async function resolveMappings(input, deps) {
598
+ try {
599
+ const references = input.items.filter((item) => nonempty(item.reference.kind) && nonempty(item.reference.id)).map((item) => ({ ...item.reference }));
600
+ if (!references.length) return [];
601
+ const result = await deps.mapping.resolve({ scope: { ...input.scope }, references });
602
+ return Array.isArray(result) ? result : [];
603
+ } catch {
604
+ return null;
605
+ }
606
+ }
607
+ async function lookupProducts(ids, deps) {
608
+ try {
609
+ const products = await deps.getProducts(ids);
610
+ return Array.isArray(products) ? products : [];
611
+ } catch {
612
+ return [];
613
+ }
614
+ }
615
+ function resolveRoute(item, mappings, platform) {
616
+ if (!nonempty(item.reference.kind) || !nonempty(item.reference.id)) return unavailable(item.reference, "invalidInput");
617
+ const matches = mappings.filter((value) => isObject2(value) && sameReference(value.reference, item.reference));
618
+ if (matches.length !== 1) return unavailable(item.reference, "mappingUnresolved");
619
+ const mapping = matches[0];
620
+ if (mapping.route === "regular") return regularPrice(item);
621
+ if (mapping.route !== "appleIap" || !nonempty(mapping.productId) || mapping.productId.includes(",") || !nonempty(mapping.mappingRevision)) {
622
+ return unavailable(item.reference, "mappingUnresolved");
623
+ }
624
+ if (platform !== "ios") return unavailable(item.reference, "unsupportedPlatform");
625
+ return { status: "loading", mapping: { reference: item.reference, route: "appleIap", productId: mapping.productId, mappingRevision: mapping.mappingRevision } };
626
+ }
627
+ function regularPrice(item) {
628
+ return nonempty(item.commerceDisplayPrice) ? { reference: item.reference, status: "ready", source: "regular", displayPrice: item.commerceDisplayPrice } : unavailable(item.reference, "commercePriceUnavailable");
629
+ }
630
+ function applePrice(mapping, products) {
631
+ const matches = products.filter((value) => isObject2(value) && value.id === mapping.productId);
632
+ const product = matches[0];
633
+ if (matches.length !== 1 || !nonempty(product?.displayPrice)) return unavailable(mapping.reference, "priceUnavailable");
634
+ if (product?.type !== "nonConsumable") return unavailable(mapping.reference, "unsupportedProductType");
635
+ return {
636
+ reference: mapping.reference,
637
+ status: "ready",
638
+ source: "appleIap",
639
+ productId: mapping.productId,
640
+ mappingRevision: mapping.mappingRevision,
641
+ displayPrice: product.displayPrice
642
+ };
643
+ }
644
+ function unavailable(reference, reason) {
645
+ return { reference, status: "unavailable", reason, retryable: !["invalidInput", "unsupportedPlatform", "unsupportedProductType", "commercePriceUnavailable"].includes(reason) };
646
+ }
647
+ var isObject2 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
648
+ var nonempty = (value) => typeof value === "string" && value.trim().length > 0;
649
+ var sameReference = (value, reference) => isObject2(value) && value.kind === reference.kind && value.id === reference.id;
650
+
517
651
  // ../../core/sdk/iap/src/index.ts
518
652
  async function getPaywallConfig() {
519
653
  const r = await dispatch("capability.iap.getPaywallConfig");
@@ -561,7 +695,7 @@ function normalizeStatus(s) {
561
695
  }
562
696
  var version = 2;
563
697
  async function purchaseForVerification(input) {
564
- if (!nonempty(input?.productId) || !isUUID(input?.appAccountToken)) return failure("invalidArgument");
698
+ if (!nonempty2(input?.productId) || !isUUID(input?.appAccountToken)) return failure("invalidArgument");
565
699
  const result = await managedDispatch(() => dispatch("capability.iap.purchaseForVerification", input));
566
700
  if (result?.status === "purchased" && isAppleTransaction(result.transaction)) {
567
701
  return { status: "purchased", transaction: decodeTransaction(result.transaction) };
@@ -577,7 +711,7 @@ async function getUnfinishedTransactions() {
577
711
  return decodeFailure(result);
578
712
  }
579
713
  async function finishTransaction(input) {
580
- if (!nonempty(input?.transactionId)) return failure("invalidArgument");
714
+ if (!nonempty2(input?.transactionId)) return failure("invalidArgument");
581
715
  const result = await managedDispatch(() => dispatch("capability.iap.finishTransaction", input));
582
716
  if (result?.status === "finished" || result?.status === "notFound") return { status: result.status };
583
717
  return decodeFailure(result);
@@ -585,13 +719,13 @@ async function finishTransaction(input) {
585
719
  var appleManagedStore = { purchaseForVerification, getUnfinishedTransactions, finishTransaction };
586
720
  var transactionErrors = /* @__PURE__ */ new Set(["unsupported", "unavailable", "invalidArgument", "invalidResponse", "notEnabled", "unsupportedProductType", "unknownProduct", "verificationFailed", "persistenceFailed", "ownershipConflict", "storeError", "busy"]);
587
721
  var failure = (code) => ({ status: "failed", code });
588
- var nonempty = (value) => typeof value === "string" && value.trim().length > 0;
722
+ var nonempty2 = (value) => typeof value === "string" && value.trim().length > 0;
589
723
  var isUUID = (value) => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value) && value.length === 36;
590
- var isObject2 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
724
+ var isObject3 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
591
725
  async function managedDispatch(invoke) {
592
726
  try {
593
727
  const value = await invoke();
594
- return isObject2(value) ? value : null;
728
+ return isObject3(value) ? value : null;
595
729
  } catch {
596
730
  return failure("unavailable");
597
731
  }
@@ -600,8 +734,8 @@ function decodeFailure(value) {
600
734
  return value?.status === "failed" && transactionErrors.has(value.code) ? failure(value.code) : failure("invalidResponse");
601
735
  }
602
736
  function isAppleTransaction(value) {
603
- if (!isObject2(value)) return false;
604
- return value.store === "apple" && typeof value.signedTransaction === "string" && /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value.signedTransaction) && nonempty(value.transactionId) && nonempty(value.originalTransactionId) && nonempty(value.productId) && (value.appAccountToken === null || isUUID(value.appAccountToken)) && ["production", "sandbox", "xcode", "unknown"].includes(value.environment);
737
+ if (!isObject3(value)) return false;
738
+ return value.store === "apple" && typeof value.signedTransaction === "string" && /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value.signedTransaction) && nonempty2(value.transactionId) && nonempty2(value.originalTransactionId) && nonempty2(value.productId) && (value.appAccountToken === null || isUUID(value.appAccountToken)) && ["production", "sandbox", "xcode", "unknown"].includes(value.environment);
605
739
  }
606
740
  function decodeTransaction(value) {
607
741
  const { store, signedTransaction, transactionId, originalTransactionId, productId, appAccountToken, environment } = value;
@@ -612,6 +746,7 @@ export {
612
746
  MAX_RETRY_DELAY_MS,
613
747
  appleManagedStore,
614
748
  consume,
749
+ createPricingController,
615
750
  createPurchaseCoordinator,
616
751
  entitlements,
617
752
  finishTransaction,
package/dist/cli.mjs CHANGED
@@ -6878,6 +6878,7 @@ function resolveTranslation(value, catalog, sourceCatalog) {
6878
6878
  var BINDING_SIGILS, BINDING_SIGIL_RE, TRANSLATION_PREFIX, TRANSLATION_KEY_RE;
6879
6879
  var init_bindings = __esm({
6880
6880
  "../../core/ts/src/bindings.ts"() {
6881
+ "use strict";
6881
6882
  BINDING_SIGILS = ["service", "flag", "item", "t"];
6882
6883
  BINDING_SIGIL_RE = new RegExp(
6883
6884
  `^\\$(?:${BINDING_SIGILS.filter((s2) => s2 !== "t").join("|")})\\b|^\\$t:`
@@ -13431,6 +13432,7 @@ function resolveIdiom(tokens2, ctx = {}) {
13431
13432
  var IDIOM_CONTEXTS, UNSAFE_KEYS;
13432
13433
  var init_idiom = __esm({
13433
13434
  "../../core/ts/src/idiom.ts"() {
13435
+ "use strict";
13434
13436
  IDIOM_CONTEXTS = {
13435
13437
  ios: { native: ["ios"] },
13436
13438
  android: { native: ["material"] },
@@ -0,0 +1,225 @@
1
+ # Shared Apple IAP pricing
2
+
3
+ `@himalaya/iap` resolves read-only payment mappings and retrieves Apple metadata for
4
+ cards, details, and checkout previews. It neither buys anything nor proves access.
5
+ The mapping provider is an internal client port; a deployed production backend is
6
+ still required. Do not call the purchase-delivery coordinator for price lookup.
7
+
8
+ ## Inputs and states
9
+
10
+ Create one controller per worker/service with `createPricingController({ mapping,
11
+ getProducts, onState })`. Inject the existing SDK `getProducts` as the real store
12
+ adapter; tests may inject another function. `load(input)` accepts:
13
+
14
+ - `scope`: app ID, bundle ID, Wix site ID, and optional opaque `contextRevision`.
15
+ Change the revision whenever a member/audience change affects mapping. Pass no
16
+ credential or raw member identity.
17
+ - `platform`: the actual trusted host platform. Only `ios` resolves Apple prices.
18
+ - `items`: `{ reference: { kind, id }, commerceDisplayPrice? }`. References are
19
+ unique by the pair; repeated references retain the first occurrence and its
20
+ commerce input. Different entity kinds cannot collide.
21
+
22
+ The injected batch `mapping.resolve({ scope, references })` returns one mapping
23
+ per requested reference: `appleIap` with `productId` and `mappingRevision`,
24
+ `regular`, or `unavailable`. Missing, duplicate, unknown, or malformed routes are
25
+ unresolved. This is not an agreed Scala wire contract or a deployed endpoint.
26
+
27
+ `getState()` returns a detached serializable snapshot with revision, captured
28
+ scope, and per-reference items. Loading and unavailable items contain **no price**.
29
+ Apple ready items carry `source: "appleIap"`, product ID, mapping revision, and
30
+ exact StoreKit `displayPrice`; regular ready items carry `source: "regular"` and
31
+ the caller's existing commerce price. Copy remains the screen's responsibility.
32
+ When that commerce price is missing or blank, `commercePriceUnavailable` is
33
+ non-retryable: the caller must supply a usable price in a new `load(input)`.
34
+ Refreshing the same input cannot supply it.
35
+
36
+ Apple strings are displayed unchanged, including spaces and currency placement;
37
+ they are not reconstructed from micros or phone locale. See Apple's
38
+ [displayPrice contract](https://developer.apple.com/documentation/storekit/product/displayprice).
39
+ Only non-consumables are supported here. A lookup failure, empty response, missing
40
+ product, or blank price is `priceUnavailable`, not proof of a network failure or
41
+ nonexistent product: the current native adapter collapses store errors into `[]`.
42
+ There is never a Wix price or external-checkout fallback for an Apple route.
43
+
44
+ `load` batches unique Apple IDs and joins identical work already in flight within
45
+ this controller. Input identity includes scope, platform, item order/references,
46
+ and commerce display prices. Every call after completion performs a fresh lookup.
47
+ `refresh()` reloads the last captured input, also joining identical in-flight work.
48
+ Changing input invalidates prior work before its results can publish; obsolete
49
+ mapping results do not start a store lookup. `dispose()` clears readable state,
50
+ suppresses later publications/calls, and cannot cancel store/network I/O already
51
+ sent. No persistent or app-wide cache exists; separate workers perform independent
52
+ lookups.
53
+
54
+ ## Complete Tier-5 service example
55
+
56
+ This example is compiled and executed by `pricing-integration.test.ts` against the
57
+ actual worker bootstrap and SDK dispatch seam. Register it separately in each
58
+ screen worker, injecting the mapping provider and trusted initial input. It is a
59
+ checkout **preview**: purchase readiness stays false independently of metadata.
60
+
61
+ ```ts
62
+ // pricing-worker.ts
63
+ import { defineService } from "@himalaya/state";
64
+ import {
65
+ createPricingController, getProducts,
66
+ type PricingInput, type PricingMappingProvider, type PricingState,
67
+ } from "@himalaya/iap";
68
+
69
+ export interface PriceCopy {
70
+ loading: string;
71
+ unavailable: string;
72
+ apple: string;
73
+ regular: string;
74
+ }
75
+
76
+ interface PriceRow {
77
+ id: string;
78
+ status: "loading" | "ready" | "unavailable";
79
+ displayPrice: string;
80
+ message: string;
81
+ retryable: boolean;
82
+ loading: boolean;
83
+ hideLoading: boolean;
84
+ hideRetry: boolean;
85
+ }
86
+
87
+ interface PriceServiceState {
88
+ pricing: PricingState;
89
+ rows: PriceRow[];
90
+ canPurchase: boolean;
91
+ purchaseDisabled: boolean;
92
+ }
93
+
94
+ export function registerPricingService(
95
+ mapping: PricingMappingProvider, initialInput: PricingInput, copy: PriceCopy,
96
+ ) {
97
+ let input = initialInput;
98
+ let disposed = false;
99
+ const controller = createPricingController({
100
+ mapping, getProducts,
101
+ onState: () => { void service.publish(); },
102
+ });
103
+
104
+ function project(pricing: PricingState): PriceRow[] {
105
+ return pricing.items.map(item => ({
106
+ id: JSON.stringify([item.reference.kind, item.reference.id]),
107
+ status: item.status,
108
+ displayPrice: item.status === "ready" ? item.displayPrice : "",
109
+ message: item.status === "loading" ? copy.loading : item.status === "unavailable"
110
+ ? copy.unavailable : item.source === "appleIap" ? copy.apple : copy.regular,
111
+ retryable: item.status === "unavailable" && item.retryable,
112
+ loading: item.status === "loading",
113
+ hideLoading: item.status !== "loading",
114
+ hideRetry: item.status !== "unavailable" || !item.retryable,
115
+ }));
116
+ }
117
+
118
+ const actions = {
119
+ start: (state: PriceServiceState) => ({ ...state, _poll: {
120
+ id: "pricing", action: "pricing.reload", intervalMs: 60_000, immediate: true,
121
+ } }),
122
+ publish: (state: PriceServiceState) => {
123
+ const pricing = controller.getState();
124
+ return { ...state, pricing, rows: project(pricing), canPurchase: false, purchaseDisabled: true };
125
+ },
126
+ reload: async () => {
127
+ if (!disposed) await controller.load(input);
128
+ // Publication uses a separate reducer; returning captured state would race.
129
+ return undefined;
130
+ },
131
+ retry: async () => {
132
+ if (!disposed) await controller.refresh();
133
+ return undefined;
134
+ },
135
+ setInput: async (_state: PriceServiceState, next: PricingInput) => {
136
+ if (!disposed) { input = next; await controller.load(input); }
137
+ return undefined;
138
+ },
139
+ dispose: (state: PriceServiceState) => {
140
+ disposed = true;
141
+ controller.dispose();
142
+ return { ...state, pricing: controller.getState(), rows: [], canPurchase: false, purchaseDisabled: true };
143
+ },
144
+ };
145
+ const initialPricing: PricingState = { ...controller.getState(), items: initialInput.items.map(item => ({
146
+ reference: { kind: item.reference.kind, id: item.reference.id }, status: "loading",
147
+ })) };
148
+ const service = defineService<PriceServiceState, typeof actions>("pricing", {
149
+ state: { pricing: initialPricing, rows: project(initialPricing), canPurchase: false, purchaseDisabled: true },
150
+ notHydrated: ["pricing", "rows", "canPurchase", "purchaseDisabled"], actions,
151
+ });
152
+ void service.start();
153
+ return service;
154
+ }
155
+ ```
156
+
157
+ The bundle must declare `iap`. To derive platform/app identity from the host,
158
+ declare `app_info` too and call `@himalaya/app-info.get()` before registering the
159
+ service. Use its `platform`, `appId`, and `bundleId`, with the caller's site and
160
+ opaque context revision. If the call rejects or identity is absent, keep the
161
+ Apple route unavailable; never assume iOS. Its `environment: dev | prod` describes
162
+ Himalaya serving, **not** Apple Sandbox/production. Do not introduce an app-info
163
+ dependency into the pricing package itself.
164
+
165
+ Bind price text to `$service:pricing.rows.0.displayPrice` for a single card/detail/
166
+ checkout item, and use the existing bound list template for multiple rows. Bind
167
+ loading visibility to `hideLoading`, Retry visibility to `hideRetry`, and the
168
+ purchase button's `disabledPath` to `$service:pricing.purchaseDisabled`. The Retry
169
+ action is `tier5.pricing.retry`. Existing Text, ActivityIndicator, and Button components
170
+ are sufficient; keep labels in the consuming copy pack and use stable component
171
+ IDs. Regular commerce route/checkout remains owned by the existing commerce flow.
172
+
173
+ ## Lifecycle and purchase boundaries
174
+
175
+ The service arms `_poll` at module evaluation with an immediate first load and a
176
+ 60-second visible cadence. The **host** schedules it: worker JS has no ordinary
177
+ timers. Current iOS pooling pauses off-screen polls, resumes immediately on return,
178
+ and the lifecycle scheduler pauses in the background and fires on resume. Worker
179
+ teardown cancels polling. `_poll` has no explicit stop directive; a disposed
180
+ service ignores subsequent ticks until its worker is destroyed.
181
+
182
+ Use the same module-evaluation registration on card, detail, and checkout entry;
183
+ checkout opens with a fresh controller/lookup, or call `reload` if retaining its
184
+ controller. Route supported caller scope/reference changes through `setInput`.
185
+ Do not use descriptor `onAppear` as a reliable iOS return callback: retained
186
+ screens do not necessarily remount. Prices and derived fields must be excluded
187
+ from persisted worker hydration, as above, on hosts supporting `notHydrated`.
188
+
189
+ Every new lookup clears the amount immediately. A failed periodic/background
190
+ refresh shows unavailable with Retry rather than keeping a prior amount as freshly
191
+ confirmed. Overlapping entry, poll, checkout, and Retry requests join matching
192
+ in-flight work. No polling continues while the iOS host has pooled/backgrounded
193
+ the worker.
194
+
195
+ No Storefront update bridge is implemented in this slice. Supported invalidations
196
+ refresh prices; a storefront change while visible may remain undetected until the
197
+ next poll or explicit refresh. This does not establish immediate storefront
198
+ notification. Native `Storefront.updates` remains a separate follow-up.
199
+
200
+ Price metadata is not purchase authorization or entitlement. A future activated
201
+ checkout must compare its purchase context's Apple product ID with the displayed
202
+ ready state; if they differ, refresh mapping/price and show the new amount before
203
+ allowing purchase. The existing purchase coordinator remains unchanged, and this
204
+ preview never invokes it. Mapping fixtures and their failure scenarios belong only
205
+ in the local demonstration. Production mapping, checkout activation, and template
206
+ migration are still required before rollout.
207
+
208
+ The dedicated iOS LiftGear QA binary opts into the existing managed transaction
209
+ API before shared capability registration. Its native identity requires both
210
+ `com.alexchudnovsky.liftgear` and baked app
211
+ `alexanderch3.fit-gear-2-push-test`; the tenant is `liftgear-sandbox-lab`.
212
+ This opt-in cannot be selected by worker input, member identity, or OTA content.
213
+ Other Branded Lite identities retain their existing registration behavior.
214
+ For this dedicated QA identity, a pinned baked config with the
215
+ `liftgear-pricing` scheme and a pricing-card home supplies the navigation menu
216
+ and initial screen. This keeps the embedded demonstration visible after an
217
+ update over an existing LiftGear installation or a live config refresh, without
218
+ changing stored member data or managed purchase policy. Ordinary or incomplete
219
+ baked configs continue to use the cached navigation. This is a bounded preview
220
+ host rule, not a general fix for distribution release routing.
221
+ The separate, explicitly armed purchase demonstration calls
222
+ `purchaseForVerification` with a labelled synthetic association, reports Apple's
223
+ actual transaction environment, and leaves the transaction unfinished. It has
224
+ no transaction-server integration or program-access delivery. Pricing alone
225
+ does not arm the demonstration or start a purchase.
package/guides/index.json CHANGED
@@ -178,6 +178,12 @@
178
178
  "category": "Workers & SDKs",
179
179
  "file": "capabilities.md"
180
180
  },
181
+ {
182
+ "id": "iap-pricing",
183
+ "title": "Apple IAP pricing in a worker",
184
+ "category": "Workers & SDKs",
185
+ "file": "iap-pricing.md"
186
+ },
181
187
  {
182
188
  "id": "wix-api-usage",
183
189
  "title": "Call Wix APIs from a worker",
@@ -1705,6 +1705,78 @@
1705
1705
  "jsdoc": "/** Creates a coordinator owned by the host/service lifecycle, not by a screen or render.\n * Closures, not classes: Tier-5 bundles must also evaluate on Hermes, which has no `class`. */",
1706
1706
  "decl": "export function createPurchaseCoordinator(deps: PurchaseCoordinatorDependencies): PurchaseCoordinator;"
1707
1707
  },
1708
+ {
1709
+ "name": "CatalogReference",
1710
+ "kind": "interface",
1711
+ "jsdoc": "",
1712
+ "decl": "export interface CatalogReference {\n kind: string;\n id: string;\n}"
1713
+ },
1714
+ {
1715
+ "name": "PricingScope",
1716
+ "kind": "interface",
1717
+ "jsdoc": "/** Opaque context revision changes whenever a mapping-dependent audience changes. */",
1718
+ "decl": "export interface PricingScope {\n appId: string;\n bundleId: string;\n siteId: string;\n contextRevision?: string;\n}"
1719
+ },
1720
+ {
1721
+ "name": "PricingItem",
1722
+ "kind": "interface",
1723
+ "jsdoc": "",
1724
+ "decl": "export interface PricingItem {\n reference: CatalogReference;\n /** Owned/formatted by the commerce caller; used only for an explicit regular route. */\n commerceDisplayPrice?: string;\n}"
1725
+ },
1726
+ {
1727
+ "name": "PricingInput",
1728
+ "kind": "interface",
1729
+ "jsdoc": "",
1730
+ "decl": "export interface PricingInput {\n scope: PricingScope;\n /** Trusted host platform; only \"ios\" performs Apple lookup. */\n platform: string;\n items: PricingItem[];\n}"
1731
+ },
1732
+ {
1733
+ "name": "PricingMapping",
1734
+ "kind": "type",
1735
+ "jsdoc": "",
1736
+ "decl": "export type PricingMapping = { reference: CatalogReference } & (\n | { route: \"appleIap\"; productId: string; mappingRevision: string }\n | { route: \"regular\" }\n | { route: \"unavailable\" }\n);"
1737
+ },
1738
+ {
1739
+ "name": "PricingMappingProvider",
1740
+ "kind": "interface",
1741
+ "jsdoc": "/** Internal client port, not an agreed/deployed backend API. Never requires purchase context. */",
1742
+ "decl": "export interface PricingMappingProvider {\n resolve(input: { scope: PricingScope; references: CatalogReference[] }): Promise<PricingMapping[]>;\n}"
1743
+ },
1744
+ {
1745
+ "name": "PricingUnavailableReason",
1746
+ "kind": "type",
1747
+ "jsdoc": "",
1748
+ "decl": "export type PricingUnavailableReason = \"invalidInput\" | \"mappingUnavailable\" | \"mappingUnresolved\"\n | \"unsupportedPlatform\" | \"unsupportedProductType\" | \"priceUnavailable\" | \"commercePriceUnavailable\";"
1749
+ },
1750
+ {
1751
+ "name": "ItemPriceState",
1752
+ "kind": "type",
1753
+ "jsdoc": "",
1754
+ "decl": "export type ItemPriceState = { reference: CatalogReference } & (\n | { status: \"loading\" }\n | { status: \"ready\"; source: \"appleIap\"; displayPrice: string; productId: string; mappingRevision: string }\n | { status: \"ready\"; source: \"regular\"; displayPrice: string }\n | { status: \"unavailable\"; reason: PricingUnavailableReason; retryable: boolean }\n);"
1755
+ },
1756
+ {
1757
+ "name": "PricingState",
1758
+ "kind": "interface",
1759
+ "jsdoc": "/** Contains no credentials, transaction proof, entitlement, or purchase authorization. */",
1760
+ "decl": "export interface PricingState {\n revision: number;\n scope: PricingScope | null;\n items: ItemPriceState[];\n}"
1761
+ },
1762
+ {
1763
+ "name": "PricingDependencies",
1764
+ "kind": "interface",
1765
+ "jsdoc": "",
1766
+ "decl": "export interface PricingDependencies {\n mapping: PricingMappingProvider;\n getProducts(productIds: string[]): Promise<Product[]>;\n onState?(state: PricingState): void;\n}"
1767
+ },
1768
+ {
1769
+ "name": "PricingController",
1770
+ "kind": "interface",
1771
+ "jsdoc": "",
1772
+ "decl": "export interface PricingController {\n /** Fresh lookup; identical in-flight input is shared. Completed prices are never cached. */\n load(input: PricingInput): Promise<void>;\n /** Reloads the last input, joining a matching lookup already in progress. */\n refresh(): Promise<void>;\n getState(): PricingState;\n /** Clears state and suppresses subsequent calls/publications; cannot cancel native I/O. */\n dispose(): void;\n}"
1773
+ },
1774
+ {
1775
+ "name": "createPricingController",
1776
+ "kind": "function",
1777
+ "jsdoc": "/** Creates one worker's read-only pricing controller with injected mapping, store lookup, and state publication. */",
1778
+ "decl": "export function createPricingController(deps: PricingDependencies): PricingController;"
1779
+ },
1708
1780
  {
1709
1781
  "name": "ProductType",
1710
1782
  "kind": "type",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.1240.0",
3
+ "version": "0.1241.0",
4
4
  "type": "module",
5
5
  "description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
6
6
  "keywords": [
@@ -67,5 +67,5 @@
67
67
  "artifactId": "himalaya-cli"
68
68
  }
69
69
  },
70
- "falconPackageHash": "fa100c156434113a7befa8bdb97c6f87ee57d27d3e088aa3b2a9752c"
70
+ "falconPackageHash": "5e95fe6c20dcd9b9fbc99564f2535787252632211a307e0d5b9908f9"
71
71
  }