@wireai/activation 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/AGENTS.md +51 -0
  2. package/CHANGELOG.md +58 -3
  3. package/INTEGRATION_PROMPT.md +13 -1
  4. package/README.md +73 -0
  5. package/dist/analytics/index.d.mts +17 -6
  6. package/dist/analytics/index.d.ts +17 -6
  7. package/dist/analytics/index.js +130 -35
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +126 -36
  10. package/dist/analytics/index.mjs.map +1 -1
  11. package/dist/{currentSession-DdnUq2HQ.d.ts → currentSession-D6RiVtc8.d.ts} +88 -30
  12. package/dist/{currentSession-C0_odnIW.d.mts → currentSession-DsSDHqor.d.mts} +88 -30
  13. package/dist/index.d.mts +236 -36
  14. package/dist/index.d.ts +236 -36
  15. package/dist/index.js +281 -20
  16. package/dist/index.js.map +1 -1
  17. package/dist/index.mjs +269 -21
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/questionnaire/index.d.mts +1 -1
  20. package/dist/questionnaire/index.d.ts +1 -1
  21. package/dist/questionnaire/index.js +49 -6
  22. package/dist/questionnaire/index.js.map +1 -1
  23. package/dist/questionnaire/index.mjs +49 -6
  24. package/dist/questionnaire/index.mjs.map +1 -1
  25. package/dist/reviews/index.d.mts +2 -2
  26. package/dist/reviews/index.d.ts +2 -2
  27. package/dist/reviews/index.js +73 -8
  28. package/dist/reviews/index.js.map +1 -1
  29. package/dist/reviews/index.mjs +73 -8
  30. package/dist/reviews/index.mjs.map +1 -1
  31. package/dist/{transport-BGW9uXZJ.d.mts → transport-CF_eHwzC.d.mts} +15 -1
  32. package/dist/{transport-jUJd5kxu.d.ts → transport-DsRe4epC.d.ts} +15 -1
  33. package/llms.txt +1 -0
  34. package/package.json +1 -1
  35. package/src/activation/useWireActivation.ts +12 -1
  36. package/src/activation/wireActivation.ts +36 -24
  37. package/src/analytics/analyticsFacade.ts +41 -20
  38. package/src/analytics/currentSession.ts +83 -0
  39. package/src/analytics/eventQueue.ts +9 -1
  40. package/src/analytics/index.ts +20 -1
  41. package/src/analytics/reportClientEvent.ts +38 -0
  42. package/src/analytics/screenTracking.ts +6 -1
  43. package/src/analytics/useAnalytics.ts +22 -1
  44. package/src/context/deviceId.ts +109 -0
  45. package/src/context/userContext.ts +18 -0
  46. package/src/index.ts +45 -1
  47. package/src/questionnaire/runtime.ts +12 -2
  48. package/src/questionnaire/useQuestionnaireGate.ts +9 -7
  49. package/src/revenuecat/index.ts +55 -0
  50. package/src/revenuecat/purchaseEvents.ts +167 -0
  51. package/src/revenuecat/revenueCatBridge.ts +221 -0
  52. package/src/revenuecat/types.ts +95 -0
  53. package/src/reviews/runtime.ts +92 -1
  54. package/src/reviews/transport.ts +21 -2
  55. package/src/reviews/useReviewGate.ts +12 -7
  56. package/src/session-analytics/lifecycle.ts +9 -2
  57. package/src/session-analytics/reportSessionStart.ts +15 -4
  58. package/src/session-analytics/useLifecycleEvents.ts +28 -2
@@ -41,3 +41,112 @@ export const mintDeviceId = (): string => {
41
41
  const time = Date.now().toString(36);
42
42
  return `${AUTO_DEVICE_ID_PREFIX}${time}_${randomChunk()}${randomChunk()}`;
43
43
  };
44
+
45
+ // ── The ONE auto device key per install ──────────────────────────────────────────────────────
46
+ //
47
+ // WHY A REGISTRY AND NOT A `let` PER FACTORY: `createAnalytics` and `createWireActivation` each
48
+ // used to mint their OWN id synchronously and then race a storage read to overwrite it. A host that
49
+ // creates BOTH (the documented wiring: a façade for `track`/`screen`, an activation instance for the
50
+ // gate-firing `wire.track`) therefore had TWO auto ids for ONE install. Every event carried whichever
51
+ // id its own surface minted, so the `device_key` the server groups a device's sessions under — the
52
+ // key `min_sessions`, A/B arm stickiness, and the purchase↔onboarding join all read — SPLIT in two.
53
+ // On a first run both also wrote their own id to the same storage slot, so which one survived was a
54
+ // coin flip. Same failure class as joining two event families on disjoint id spaces: no error, just
55
+ // halved counts and a join that misses.
56
+ //
57
+ // The fix is the pattern this repo already uses for `currentSession` and `activation revalidation`:
58
+ // ONE value in a `globalThis` slot keyed by `Symbol.for(...)`, so every inlined copy of this module
59
+ // (tsup duplicates modules across the `.` / `./analytics` bundles) addresses the SAME registry.
60
+ // Keyed by `appId` so two tenants in one process never share an id.
61
+ //
62
+ // RESIDUAL WINDOW (documented, not fixed here): the storage read is async, so events emitted in the
63
+ // milliseconds before hydration completes still carry the freshly minted id rather than the persisted
64
+ // one. The registry makes every surface agree on WHICH id that is; it does not make the read sync.
65
+
66
+ /** Well-known key into the runtime-global symbol registry — one auto-id registry across every bundle. */
67
+ const AUTO_DEVICE_KEY_SLOT: unique symbol = Symbol.for("@wireai/activation:autoDeviceKeys");
68
+
69
+ /** The shared registry: the live id per `appId`, plus the set of appIds whose hydration already ran. */
70
+ type AutoDeviceKeyRegistry = { keys: Map<string, string>; hydrating: Set<string> };
71
+
72
+ type GlobalWithDeviceKeys = typeof globalThis & {
73
+ [AUTO_DEVICE_KEY_SLOT]?: AutoDeviceKeyRegistry;
74
+ };
75
+
76
+ const deviceKeyGlobal = globalThis as GlobalWithDeviceKeys;
77
+
78
+ const autoDeviceKeyRegistry = (): AutoDeviceKeyRegistry => {
79
+ const existing = deviceKeyGlobal[AUTO_DEVICE_KEY_SLOT];
80
+ if (existing) return existing;
81
+ const created: AutoDeviceKeyRegistry = { keys: new Map(), hydrating: new Set() };
82
+ deviceKeyGlobal[AUTO_DEVICE_KEY_SLOT] = created;
83
+ return created;
84
+ };
85
+
86
+ /** The persistence subset {@link resolveAutoDeviceKey} needs (a strict subset of `WireOnboardingStorage`). */
87
+ export type DeviceKeyStorage = {
88
+ getItem(key: string): Promise<string | null>;
89
+ setItem(key: string, value: string): Promise<void>;
90
+ };
91
+
92
+ /** Options for {@link resolveAutoDeviceKey}. Omitting `storage` gives a PROCESS-scoped id, not a
93
+ * per-install one — see the caller notes: a caller with no persistence must decide whether a
94
+ * per-launch id is better or worse than no id for its metric. */
95
+ export interface ResolveAutoDeviceKeyOptions {
96
+ /** Tenant/app id — namespaces both the registry entry and the storage slot. */
97
+ appId?: string;
98
+ /** Host persistence. Present → the id survives launches. Absent → process-scoped only. */
99
+ storage?: DeviceKeyStorage;
100
+ }
101
+
102
+ /**
103
+ * The ONE auto-minted `device_key` for an install, shared by every kit surface.
104
+ *
105
+ * SYNCHRONOUS by contract (a fire-and-forget event path cannot await): returns the current live id
106
+ * immediately, minting one on first call. When `storage` is supplied it also kicks off a SINGLE
107
+ * hydration per `appId` that adopts the persisted id (or persists the freshly minted one). Callers
108
+ * should call this per EVENT rather than caching the return value, so an event built after hydration
109
+ * carries the persisted id.
110
+ *
111
+ * A host-supplied `deviceKey` always wins — callers must short-circuit before reaching this.
112
+ * Never throws: a missing, hung, or rejecting storage adapter degrades to the in-memory id.
113
+ */
114
+ export const resolveAutoDeviceKey = (opts: ResolveAutoDeviceKeyOptions = {}): string => {
115
+ const registry = autoDeviceKeyRegistry();
116
+ const appId = opts.appId ?? "default";
117
+
118
+ let id = registry.keys.get(appId);
119
+ if (!id) {
120
+ id = mintDeviceId();
121
+ registry.keys.set(appId, id);
122
+ }
123
+
124
+ const storage = opts.storage;
125
+ // Single-flight: the FIRST caller with storage owns hydration for this appId; later callers just
126
+ // read whatever the registry currently holds.
127
+ if (storage && !registry.hydrating.has(appId)) {
128
+ registry.hydrating.add(appId);
129
+ const slot = deviceIdStorageKey(appId);
130
+ const minted = id;
131
+ try {
132
+ void Promise.resolve(storage.getItem(slot))
133
+ .then((saved) => {
134
+ const persisted = typeof saved === "string" && saved.trim() ? saved.trim() : undefined;
135
+ if (persisted) registry.keys.set(appId, persisted);
136
+ else void Promise.resolve(storage.setItem(slot, minted)).catch(() => {});
137
+ })
138
+ .catch(() => {});
139
+ } catch {
140
+ // A storage adapter that throws synchronously — degrade to the in-memory id.
141
+ }
142
+ }
143
+
144
+ return registry.keys.get(appId) ?? id;
145
+ };
146
+
147
+ /** Test-only: forget every auto id + hydration flag so a unit test starts from a clean registry. */
148
+ export const resetAutoDeviceKeys = (): void => {
149
+ const registry = autoDeviceKeyRegistry();
150
+ registry.keys.clear();
151
+ registry.hydrating.clear();
152
+ };
@@ -207,6 +207,24 @@ export const clearUserContext = async (opts: ClearUserContextOptions = {}): Prom
207
207
  }
208
208
  };
209
209
 
210
+ /**
211
+ * The `userContext` value to hand `<WireOnboarding userContext={...} />` so an onboarding session
212
+ * and the app's later events (purchases, actions, screens) share ONE join key.
213
+ *
214
+ * WHY it exists as a named function instead of an inline object literal: the wire key is
215
+ * `device_key`, the prop-facing name is `deviceKey`, and the analytics surfaces auto-mint the value
216
+ * for you. A host that hand-writes `userContext={{ deviceKey }}` produces a bucket the server's
217
+ * device lookup does not read, and the resulting funnel is silently EMPTY rather than wrong. This is
218
+ * the one place that spelling is decided.
219
+ *
220
+ * Pass the SAME `deviceKey` you gave `createAnalytics` / `createWireActivation`. `session_id` is not
221
+ * a join key across those two families: an onboarding session id is the A2A `contextId` and an
222
+ * app-event session id is the per-open id, so intersecting them returns nothing.
223
+ */
224
+ export const activationJoinContext = (
225
+ deviceKey: string,
226
+ ): Record<string, string | number | boolean> => resolveUserContext({ deviceKey }).userContext ?? {};
227
+
210
228
  /** Options for {@link resolveUserContext}. */
211
229
  export interface ResolveUserContextOptions {
212
230
  /**
package/src/index.ts CHANGED
@@ -153,6 +153,7 @@ export {
153
153
  clearUserContext,
154
154
  clearPiiFromContext,
155
155
  analyticsUserIdStorageKey,
156
+ activationJoinContext,
156
157
  RESERVED_USER_CONTEXT_KEYS,
157
158
  EXTRA_KEY_PREFIX,
158
159
  } from "./context/userContext";
@@ -162,11 +163,27 @@ export type {
162
163
  ResolveUserContextOptions,
163
164
  ClearUserContextOptions,
164
165
  } from "./context/userContext";
165
- export { mintDeviceId, deviceIdStorageKey, AUTO_DEVICE_ID_PREFIX } from "./context/deviceId";
166
+ export {
167
+ mintDeviceId,
168
+ deviceIdStorageKey,
169
+ AUTO_DEVICE_ID_PREFIX,
170
+ // `resolveAutoDeviceKey` is the id `createAnalytics` / `createWireActivation` auto-mint and persist.
171
+ // It MUST be public: the documented purchase↔onboarding join is
172
+ // `<WireOnboarding userContext={activationJoinContext(deviceKey)} />`, and a host that owns NO device
173
+ // id of its own had no way to obtain the key the analytics side was already stamping — so its
174
+ // purchase events carried `wdev_*` while its onboarding session carried no `device_key` at all, and
175
+ // the join returned the silent zero the README warns about.
176
+ resolveAutoDeviceKey,
177
+ resetAutoDeviceKeys,
178
+ } from "./context/deviceId";
179
+ export type { DeviceKeyStorage, ResolveAutoDeviceKeyOptions } from "./context/deviceId";
166
180
 
167
181
  // ─── Current per-open session registry (identify/app-events reuse the live session) ───
168
182
  export {
169
183
  getCurrentSessionId,
184
+ // Returns the registered per-open id, minting + registering one when no app-open has been
185
+ // registered yet — so no wire path can emit the empty `session_id` the server drops behind a 200.
186
+ ensureCurrentSessionId,
170
187
  setCurrentSessionId,
171
188
  resetCurrentSessionId,
172
189
  } from "./analytics/currentSession";
@@ -189,6 +206,33 @@ export {
189
206
  resetActivationRevalidation,
190
207
  } from "./activation";
191
208
 
209
+ // ─── RevenueCat (purchase funnel → the same events stream, joined on device_key) ──
210
+ export {
211
+ createRevenueCatBridge,
212
+ WIRE_PURCHASE_EVENTS,
213
+ PLAN_TIER_CONTEXT_KEY,
214
+ activeEntitlement,
215
+ describeEntitlement,
216
+ describeFailure,
217
+ describePackage,
218
+ isUserCancelled,
219
+ resolvePlanTier,
220
+ } from "./revenuecat";
221
+ export type {
222
+ RevenueCatBridge,
223
+ RevenueCatBridgeConfig,
224
+ PurchaseProps,
225
+ WirePurchaseEventName,
226
+ PlanTier,
227
+ RevenueCatCustomerInfoLike,
228
+ RevenueCatEntitlementLike,
229
+ RevenueCatErrorLike,
230
+ RevenueCatOfferingLike,
231
+ RevenueCatPackageLike,
232
+ RevenueCatProductLike,
233
+ RevenueCatSink,
234
+ } from "./revenuecat";
235
+
192
236
  // ─── Session mapping (one `app.session_started` per app-open → /v1/events) ─────
193
237
  export {
194
238
  reportSessionStart,
@@ -5,7 +5,13 @@
5
5
  * host that mounted CoachmarkProvider gets questionnaire gating for free - no second storage
6
6
  * to wire. Once-per-user is keyed on the questionnaire `id`.
7
7
  */
8
- export { resolveStorage, readInt, writeInt } from "../reviews/runtime";
8
+ export {
9
+ resolveStorage,
10
+ readInt,
11
+ writeInt,
12
+ bumpSessionCount,
13
+ currentOpenId,
14
+ } from "../reviews/runtime";
9
15
 
10
16
  /** Once-gate key. Keyed by app version when `oncePerVersion` is on, so a new release re-enables. */
11
17
  export const questionnaireSeenKey = (id: string, version?: string): string =>
@@ -15,6 +21,10 @@ export const questionnaireSeenKey = (id: string, version?: string): string =>
15
21
  export const questionnaireLastShownKey = (id: string): string =>
16
22
  `wire_questionnaire_${id}_last`;
17
23
 
18
- /** Session-count key, incremented once per gate mount, for the min-sessions rule. */
24
+ /** Session-count key, incremented once per APP-OPEN (not per mount), for the min-sessions rule. */
19
25
  export const questionnaireSessionsKey = (id: string): string =>
20
26
  `wire_questionnaire_${id}_sessions`;
27
+
28
+ /** Companion key holding the open id the counter was LAST incremented for (see `bumpSessionCount`). */
29
+ export const questionnaireSessionOpenKey = (id: string): string =>
30
+ `wire_questionnaire_${id}_open`;
@@ -20,8 +20,10 @@ import { useResolvedFeatures } from "../features/WireFeaturesProvider";
20
20
  import { sameDecision, shallowEqual } from "../reviews/equality";
21
21
  import { decideQuestionnaire, evaluateGate, resolveRules } from "./decision";
22
22
  import {
23
+ bumpSessionCount,
23
24
  questionnaireLastShownKey,
24
25
  questionnaireSeenKey,
26
+ questionnaireSessionOpenKey,
25
27
  questionnaireSessionsKey,
26
28
  readInt,
27
29
  resolveStorage,
@@ -71,14 +73,14 @@ export const useQuestionnaireGate = ({
71
73
  );
72
74
  const lastKey = questionnaireLastShownKey(config.id);
73
75
  const sessionsKey = questionnaireSessionsKey(config.id);
76
+ const sessionOpenKey = questionnaireSessionOpenKey(config.id);
74
77
 
75
- // Read (and bump) the session counter ONCE per mount: this mount is a new session.
76
- const sessions = useState(() => {
77
- const store = resolveStorage(storage);
78
- const next = readInt(store, sessionsKey) + 1;
79
- writeInt(store, sessionsKey, next);
80
- return next;
81
- })[0];
78
+ // Read (and bump) the app-open counter. IDEMPOTENT per app-open, NOT per mount see the same note
79
+ // in `useReviewGate`; `bumpSessionCount` keys off the live per-open session id, so a remount or a
80
+ // StrictMode double-invoke of this initializer reads the same number back instead of inflating it.
81
+ const sessions = useState(() =>
82
+ bumpSessionCount(resolveStorage(storage), sessionsKey, sessionOpenKey),
83
+ )[0];
82
84
 
83
85
  // Gate the local rules behind an optional client-side timeout, so a reachable server gets a
84
86
  // window to answer first. A present `decision` bypasses the wait entirely.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * revenuecat - the drop-in RevenueCat to Wire activation path.
3
+ *
4
+ * Re-exported from the main `@wireai/activation` barrel (no separate subpath): it is a pure,
5
+ * dependency-free mapping layer with no UI, so it costs an analytics-only consumer nothing.
6
+ *
7
+ * Adopting it is a constructor plus your existing paywall call sites:
8
+ *
9
+ * import { createAnalytics, createRevenueCatBridge, activationJoinContext } from "@wireai/activation";
10
+ *
11
+ * const analytics = createAnalytics({ serverUrl, apiKey, storage, userContext: { deviceKey } });
12
+ * const revenuecat = createRevenueCatBridge({ analytics, entitlementId: "pro" });
13
+ *
14
+ * revenuecat.paywallShown(offering, { source: variant });
15
+ * revenuecat.checkoutStarted(pkg, { source: variant });
16
+ * const entitled = revenuecat.purchaseCompleted(customerInfo, pkg, { source: variant });
17
+ *
18
+ * And the join that makes the numbers real, on the onboarding side:
19
+ *
20
+ * <WireOnboarding userContext={activationJoinContext(deviceKey)} ... />
21
+ *
22
+ * See `revenueCatBridge.ts` for why that key is `device_key` and never `session_id`.
23
+ */
24
+
25
+ // ─── The bridge (the thing you wire) ──────────────────────────────────────────
26
+ export { createRevenueCatBridge } from "./revenueCatBridge";
27
+ export type {
28
+ RevenueCatBridge,
29
+ RevenueCatBridgeConfig,
30
+ PurchaseProps,
31
+ } from "./revenueCatBridge";
32
+
33
+ // ─── Canonical purchase-funnel names + the pure mappers behind the bridge ─────
34
+ export {
35
+ WIRE_PURCHASE_EVENTS,
36
+ PLAN_TIER_CONTEXT_KEY,
37
+ activeEntitlement,
38
+ describeEntitlement,
39
+ describeFailure,
40
+ describePackage,
41
+ isUserCancelled,
42
+ resolvePlanTier,
43
+ } from "./purchaseEvents";
44
+ export type { WirePurchaseEventName, PlanTier } from "./purchaseEvents";
45
+
46
+ // ─── Structural mirrors of the react-native-purchases shapes (no native dep) ──
47
+ export type {
48
+ RevenueCatCustomerInfoLike,
49
+ RevenueCatEntitlementLike,
50
+ RevenueCatErrorLike,
51
+ RevenueCatOfferingLike,
52
+ RevenueCatPackageLike,
53
+ RevenueCatProductLike,
54
+ RevenueCatSink,
55
+ } from "./types";
@@ -0,0 +1,167 @@
1
+ /**
2
+ * purchaseEvents - the canonical Wire names for a subscription funnel, plus the PURE mappers that
3
+ * turn a RevenueCat snapshot into small, non-PII event props.
4
+ *
5
+ * Same job `analyticsEvent.ts` does for the onboarding funnel: every app was naming these events
6
+ * itself (`PURCHASE`, `purchase_success`, `AI_PAYWALL_VIEW`), so the same funnel read differently
7
+ * per tenant and no cross-app report was possible. These are the ONE set of names; the mappers are
8
+ * the ONE definition of which RevenueCat fields are safe to send.
9
+ *
10
+ * PURE + dependency-free: no React, no React Native, no `react-native-purchases`, no transport.
11
+ * Every function tolerates a malformed or absent snapshot and returns a defined value instead of
12
+ * throwing, because a paywall must never break on an analytics mapping.
13
+ */
14
+ import type {
15
+ RevenueCatCustomerInfoLike,
16
+ RevenueCatEntitlementLike,
17
+ RevenueCatPackageLike,
18
+ } from "./types";
19
+
20
+ /**
21
+ * The canonical purchase-funnel event names. These are `app_event` `question_key` values on the
22
+ * wire, so they are ALSO the exact strings a review / questionnaire firing trigger matches on.
23
+ * Never rename one: a rename silently unfires every trigger configured against the old string.
24
+ */
25
+ export const WIRE_PURCHASE_EVENTS = {
26
+ /** The paywall became visible (offerings loaded). */
27
+ paywallShown: "wire_paywall_shown",
28
+ /** The user tapped buy; the store sheet is about to open. */
29
+ checkoutStarted: "wire_checkout_started",
30
+ /** The store confirmed the purchase AND the entitlement is now active. */
31
+ purchased: "wire_purchase_completed",
32
+ /** The purchase did not land: a user cancel or a real store/network error (see `reason`). */
33
+ purchaseFailed: "wire_purchase_failed",
34
+ /** A restore ran (whether or not it produced an entitlement; see `plan_tier`). */
35
+ restored: "wire_purchase_restored",
36
+ } as const;
37
+
38
+ export type WirePurchaseEventName =
39
+ (typeof WIRE_PURCHASE_EVENTS)[keyof typeof WIRE_PURCHASE_EVENTS];
40
+
41
+ /**
42
+ * The user-context key the bridge writes the tier to. It lands NAMESPACED as
43
+ * `user_context["custom.plan_tier"]` (host extras are always prefixed), which is the flattened
44
+ * dimension the server report can break the funnel down by.
45
+ */
46
+ export const PLAN_TIER_CONTEXT_KEY = "plan_tier";
47
+
48
+ /** The three tiers the kit models. Anything richer belongs in the host's own analytics. */
49
+ export type PlanTier = "paid" | "trial" | "free";
50
+
51
+ /** Read a plain record field defensively (the snapshot comes from a native bridge). */
52
+ const asRecord = (value: unknown): Record<string, unknown> | undefined =>
53
+ typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined;
54
+
55
+ /**
56
+ * The ACTIVE entitlement matching `entitlementId`, or `undefined`. Returns `undefined` for a
57
+ * missing key, an explicitly inactive entry, and any malformed snapshot.
58
+ */
59
+ export const activeEntitlement = (
60
+ info: RevenueCatCustomerInfoLike | undefined,
61
+ entitlementId: string,
62
+ ): RevenueCatEntitlementLike | undefined => {
63
+ const active = asRecord(asRecord(asRecord(info)?.entitlements)?.active);
64
+ const found = active?.[entitlementId];
65
+ const entitlement = asRecord(found) as RevenueCatEntitlementLike | undefined;
66
+ if (!entitlement) return undefined;
67
+ // `isActive` is always present on the real type; treat an explicit `false` as not entitled and a
68
+ // missing value as entitled, because the key only appears in `entitlements.active` at all when
69
+ // RevenueCat considers it granted.
70
+ return entitlement.isActive === false ? undefined : entitlement;
71
+ };
72
+
73
+ /** True when RevenueCat says this entitlement is a free trial rather than a paid period. */
74
+ const isTrial = (entitlement: RevenueCatEntitlementLike): boolean =>
75
+ typeof entitlement.periodType === "string" && entitlement.periodType.toUpperCase() === "TRIAL";
76
+
77
+ /** `paid` / `trial` / `free` for the configured entitlement. Never throws. */
78
+ export const resolvePlanTier = (
79
+ info: RevenueCatCustomerInfoLike | undefined,
80
+ entitlementId: string,
81
+ ): PlanTier => {
82
+ const entitlement = activeEntitlement(info, entitlementId);
83
+ if (!entitlement) return "free";
84
+ return isTrial(entitlement) ? "trial" : "paid";
85
+ };
86
+
87
+ /**
88
+ * Non-PII props for an offering package: which package, which product, what it costs. Every field
89
+ * is optional on the wire, so a missing one is OMITTED rather than sent empty or invented.
90
+ */
91
+ export const describePackage = (
92
+ pkg: RevenueCatPackageLike | undefined,
93
+ ): Record<string, string | number> => {
94
+ const props: Record<string, string | number> = {};
95
+ if (!pkg) return props;
96
+ if (typeof pkg.identifier === "string") props.package_id = pkg.identifier;
97
+ if (typeof pkg.packageType === "string") props.package_type = pkg.packageType;
98
+ if (typeof pkg.offeringIdentifier === "string") props.offering_id = pkg.offeringIdentifier;
99
+ const product = asRecord(pkg.product) as RevenueCatProductFields | undefined;
100
+ if (product) {
101
+ if (typeof product.identifier === "string") props.product_id = product.identifier;
102
+ if (typeof product.price === "number" && Number.isFinite(product.price)) {
103
+ props.price = product.price;
104
+ }
105
+ if (typeof product.currencyCode === "string") props.currency = product.currencyCode;
106
+ }
107
+ return props;
108
+ };
109
+
110
+ /** The product fields `describePackage` reads, narrowed after the defensive record check. */
111
+ type RevenueCatProductFields = {
112
+ identifier?: unknown;
113
+ price?: unknown;
114
+ currencyCode?: unknown;
115
+ };
116
+
117
+ /**
118
+ * Non-PII props for the entitlement state after a purchase or a restore. Always carries
119
+ * `entitlement` + `plan_tier` so a "restore that granted nothing" is still a queryable row; the
120
+ * rest of the fields only appear when the entitlement is actually active.
121
+ */
122
+ export const describeEntitlement = (
123
+ info: RevenueCatCustomerInfoLike | undefined,
124
+ entitlementId: string,
125
+ ): Record<string, string | number | boolean> => {
126
+ const entitlement = activeEntitlement(info, entitlementId);
127
+ const props: Record<string, string | number | boolean> = {
128
+ entitlement: entitlementId,
129
+ plan_tier: entitlement ? (isTrial(entitlement) ? "trial" : "paid") : "free",
130
+ };
131
+ if (!entitlement) return props;
132
+ if (typeof entitlement.periodType === "string") props.period_type = entitlement.periodType;
133
+ props.is_trial = isTrial(entitlement);
134
+ if (typeof entitlement.willRenew === "boolean") props.will_renew = entitlement.willRenew;
135
+ if (typeof entitlement.store === "string") props.store = entitlement.store;
136
+ if (typeof entitlement.productIdentifier === "string") {
137
+ props.product_id = entitlement.productIdentifier;
138
+ }
139
+ if (typeof entitlement.isSandbox === "boolean") props.is_sandbox = entitlement.isSandbox;
140
+ return props;
141
+ };
142
+
143
+ /** RevenueCat's `PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR`. A STRING enum, value `"1"`. */
144
+ const PURCHASE_CANCELLED_CODE = "1";
145
+
146
+ /**
147
+ * True when the rejection is the user backing out of the store sheet rather than a real failure.
148
+ * Reads BOTH signals the SDK exposes: the current `code` and the deprecated-but-still-populated
149
+ * `userCancelled` flag. Anything else (including a plain `Error`) is a genuine failure.
150
+ */
151
+ export const isUserCancelled = (error: unknown): boolean => {
152
+ const record = asRecord(error);
153
+ if (!record) return false;
154
+ if (record.userCancelled === true) return true;
155
+ return record.code === PURCHASE_CANCELLED_CODE;
156
+ };
157
+
158
+ /**
159
+ * A short, stable, non-PII failure descriptor. The store's `message` is DELIBERATELY dropped: it is
160
+ * localized, unbounded, and occasionally echoes the account it failed for, so it is not something
161
+ * the kit forwards to a shared analytics backend. The RevenueCat `code` is the stable handle.
162
+ */
163
+ export const describeFailure = (error: unknown): { reason: string; code: string } => {
164
+ const record = asRecord(error);
165
+ const code = typeof record?.code === "string" ? record.code : "unknown";
166
+ return { reason: isUserCancelled(error) ? "cancelled" : "error", code };
167
+ };