@wireai/activation 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/AGENTS.md +62 -8
  2. package/CHANGELOG.md +188 -3
  3. package/INTEGRATION_PROMPT.md +25 -2
  4. package/README.md +112 -1
  5. package/dist/analytics/index.d.mts +18 -6
  6. package/dist/analytics/index.d.ts +18 -6
  7. package/dist/analytics/index.js +151 -41
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +147 -42
  10. package/dist/analytics/index.mjs.map +1 -1
  11. package/dist/coachmarks/index.d.mts +16 -0
  12. package/dist/coachmarks/index.d.ts +16 -0
  13. package/dist/coachmarks/index.js +19 -13
  14. package/dist/coachmarks/index.js.map +1 -1
  15. package/dist/coachmarks/index.mjs +19 -13
  16. package/dist/coachmarks/index.mjs.map +1 -1
  17. package/dist/{currentSession-C0_odnIW.d.mts → currentSession-BlCeDP0f.d.mts} +145 -33
  18. package/dist/{currentSession-DdnUq2HQ.d.ts → currentSession-BxEB37xt.d.ts} +145 -33
  19. package/dist/index.d.mts +243 -53
  20. package/dist/index.d.ts +243 -53
  21. package/dist/index.js +531 -156
  22. package/dist/index.js.map +1 -1
  23. package/dist/index.mjs +517 -157
  24. package/dist/index.mjs.map +1 -1
  25. package/dist/questionnaire/index.d.mts +1 -1
  26. package/dist/questionnaire/index.d.ts +1 -1
  27. package/dist/questionnaire/index.js +79 -14
  28. package/dist/questionnaire/index.js.map +1 -1
  29. package/dist/questionnaire/index.mjs +79 -14
  30. package/dist/questionnaire/index.mjs.map +1 -1
  31. package/dist/reviews/index.d.mts +2 -2
  32. package/dist/reviews/index.d.ts +2 -2
  33. package/dist/reviews/index.js +103 -16
  34. package/dist/reviews/index.js.map +1 -1
  35. package/dist/reviews/index.mjs +103 -16
  36. package/dist/reviews/index.mjs.map +1 -1
  37. package/dist/showcase/index.js +15 -6
  38. package/dist/showcase/index.js.map +1 -1
  39. package/dist/showcase/index.mjs +15 -6
  40. package/dist/showcase/index.mjs.map +1 -1
  41. package/dist/{transport-BGW9uXZJ.d.mts → transport-CF_eHwzC.d.mts} +15 -1
  42. package/dist/{transport-jUJd5kxu.d.ts → transport-DsRe4epC.d.ts} +15 -1
  43. package/llms.txt +3 -0
  44. package/package.json +1 -1
  45. package/src/WireOnboarding.tsx +140 -5
  46. package/src/activation/useWireActivation.ts +12 -1
  47. package/src/activation/wireActivation.ts +44 -25
  48. package/src/analytics/analyticsFacade.ts +54 -29
  49. package/src/analytics/currentSession.ts +83 -0
  50. package/src/analytics/eventQueue.ts +9 -1
  51. package/src/analytics/index.ts +20 -1
  52. package/src/analytics/reportClientEvent.ts +42 -0
  53. package/src/analytics/screenTracking.ts +6 -1
  54. package/src/analytics/useAnalytics.ts +22 -1
  55. package/src/coachmarks/runtime.ts +53 -17
  56. package/src/config/wireConfigFromEnv.ts +46 -2
  57. package/src/context/deviceId.ts +173 -0
  58. package/src/context/userContext.ts +18 -0
  59. package/src/index.ts +54 -2
  60. package/src/questionnaire/runtime.ts +13 -2
  61. package/src/questionnaire/useQuestionnaireGate.ts +11 -4
  62. package/src/revenuecat/index.ts +55 -0
  63. package/src/revenuecat/purchaseEvents.ts +167 -0
  64. package/src/revenuecat/revenueCatBridge.ts +221 -0
  65. package/src/revenuecat/types.ts +95 -0
  66. package/src/reviews/runtime.ts +153 -1
  67. package/src/reviews/transport.ts +21 -2
  68. package/src/reviews/useReviewGate.ts +14 -4
  69. package/src/session-analytics/lifecycle.ts +9 -2
  70. package/src/session-analytics/reportSessionStart.ts +15 -4
  71. package/src/session-analytics/useLifecycleEvents.ts +83 -27
  72. package/src/session-analytics/useSessionStart.ts +37 -1
  73. package/src/types.ts +41 -4
@@ -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
+ };
@@ -0,0 +1,221 @@
1
+ /**
2
+ * revenueCatBridge - the short, first-class path from RevenueCat to Wire activation.
3
+ *
4
+ * const revenuecat = createRevenueCatBridge({ analytics, entitlementId: "pro" });
5
+ * revenuecat.paywallShown(offering, { source: variant });
6
+ * revenuecat.checkoutStarted(pkg, { source: variant });
7
+ * const entitled = revenuecat.purchaseCompleted(customerInfo, pkg, { source: variant });
8
+ *
9
+ * WHAT IT REPLACES: every consumer was hand-rolling the same four things around its paywall,
10
+ * differently: its own event names, its own idea of which RevenueCat fields are safe to send, its
11
+ * own entitlement check duplicated at the purchase and the restore site, and its own plan-tier
12
+ * user property. This owns all four, so adopting the purchase funnel is a constructor plus five
13
+ * call sites.
14
+ *
15
+ * ── THE JOIN KEY (read this before you wire it) ───────────────────────────────────────────────
16
+ * A purchase event is only worth anything if it can be joined to the SAME user's onboarding. The
17
+ * key that does that is `user_context.device_key`, NOT `session_id`:
18
+ *
19
+ * • Purchase side: this bridge reports through a kit sink (`createAnalytics` or
20
+ * `createWireActivation`). Both auto-mint a stable, persisted, per-install `device_key` and
21
+ * stamp it on every event (see `context/deviceId.ts`).
22
+ * • Onboarding side: `<WireOnboarding userContext={activationJoinContext(deviceKey)} />`. The kit
23
+ * forwards `userContext` verbatim into the A2A session-start metadata, and the server records
24
+ * it on the session's `session_started` event, where the SAME `device_key` lookup reads it.
25
+ *
26
+ * Pass the SAME `deviceKey` to both. `session_id` is NOT a join key across these two families: the
27
+ * onboarding session id is the A2A `contextId`, the app-event session id is the per-open id, and
28
+ * they live in disjoint spaces on purpose. Intersecting them yields zero rows, every time.
29
+ *
30
+ * PURE + dependency-free: no React, no React Native, no `react-native-purchases`. Fire-and-forget
31
+ * throughout, so an analytics failure can never take the paywall down with it.
32
+ */
33
+ import type { Analytics } from "../analytics/analyticsFacade";
34
+ import type { WireActivation } from "../activation/wireActivation";
35
+ import {
36
+ PLAN_TIER_CONTEXT_KEY,
37
+ WIRE_PURCHASE_EVENTS,
38
+ describeEntitlement,
39
+ describeFailure,
40
+ describePackage,
41
+ resolvePlanTier,
42
+ type PlanTier,
43
+ } from "./purchaseEvents";
44
+ import type {
45
+ RevenueCatCustomerInfoLike,
46
+ RevenueCatOfferingLike,
47
+ RevenueCatPackageLike,
48
+ RevenueCatSink,
49
+ } from "./types";
50
+
51
+ /**
52
+ * COMPILE-TIME PROOF that both kit sinks satisfy {@link RevenueCatSink}, so a host can pass the
53
+ * analytics instance it already holds with no adapter of its own. Type-only (erased at build), and
54
+ * `tsc --noEmit` fails here the day either surface drifts. This lives in source, not the test file,
55
+ * because `tsconfig.json` excludes tests from the typecheck.
56
+ */
57
+ type AssertIsSink<T extends RevenueCatSink> = T;
58
+ type _AnalyticsIsASink = AssertIsSink<Analytics>;
59
+ type _WireActivationIsASink = AssertIsSink<WireActivation>;
60
+
61
+ /** Small, non-PII extras a host merges onto a purchase event (paywall variant, entry point, ...). */
62
+ export type PurchaseProps = Record<string, string | number | boolean>;
63
+
64
+ /** Inputs for {@link createRevenueCatBridge}. */
65
+ export interface RevenueCatBridgeConfig {
66
+ /**
67
+ * Where events go: the `createAnalytics(...)` instance (offline-first queue) or the
68
+ * `createWireActivation(...)` instance (awaitable POST). Both already carry the `device_key` this
69
+ * funnel joins on, so pass the one the app already holds rather than creating a second.
70
+ */
71
+ analytics: RevenueCatSink;
72
+ /**
73
+ * The entitlement identifier that means "paid" in THIS app, exactly as configured in the
74
+ * RevenueCat dashboard (e.g. `"pro"`). The bridge reads `customerInfo.entitlements.active[id]`.
75
+ */
76
+ entitlementId: string;
77
+ /** Non-PII props merged into EVERY event from this bridge. A per-call prop of the same name wins. */
78
+ commonProps?: PurchaseProps;
79
+ /**
80
+ * Whether entitlement changes also update the bound user context with `plan_tier`
81
+ * (`user_context["custom.plan_tier"]`), so the whole funnel can be sliced paid vs trial vs free.
82
+ * Default `true`. Requires a sink with `setUserContext` (`createAnalytics`); a no-op otherwise.
83
+ */
84
+ writePlanTier?: boolean;
85
+ }
86
+
87
+ /** The bridge surface. Every method is fire-and-forget and never throws. */
88
+ export interface RevenueCatBridge {
89
+ /**
90
+ * The paywall became visible. Pass the RevenueCat offering you just loaded and the bridge derives
91
+ * `offering_id` + `packages_count`. Fires even with no offering, because a paywall that reached
92
+ * the user with nothing to sell is exactly the failure you want in the funnel.
93
+ */
94
+ paywallShown(offering?: RevenueCatOfferingLike, props?: PurchaseProps): void;
95
+ /** The user tapped buy and the store sheet is opening. */
96
+ checkoutStarted(pkg: RevenueCatPackageLike, props?: PurchaseProps): void;
97
+ /**
98
+ * The store returned from a purchase. Fires `wire_purchase_completed` when the configured
99
+ * entitlement is now active, and `wire_purchase_failed` with `reason: "not_entitled"` when the
100
+ * store confirmed but nothing was granted (the case hosts usually drop on the floor). Syncs
101
+ * `plan_tier` either way. Returns whether the user is entitled now, so the caller can navigate.
102
+ */
103
+ purchaseCompleted(
104
+ customerInfo: RevenueCatCustomerInfoLike | undefined,
105
+ pkg?: RevenueCatPackageLike,
106
+ props?: PurchaseProps,
107
+ ): boolean;
108
+ /** The purchase call rejected. Separates a user cancel from a real store error via `reason`. */
109
+ purchaseFailed(error: unknown, pkg?: RevenueCatPackageLike, props?: PurchaseProps): void;
110
+ /** A restore finished. Reports the resulting tier and returns whether the user is entitled now. */
111
+ purchasesRestored(
112
+ customerInfo: RevenueCatCustomerInfoLike | undefined,
113
+ props?: PurchaseProps,
114
+ ): boolean;
115
+ /**
116
+ * Write the current tier to the bound user context WITHOUT emitting an event. Call it once at
117
+ * launch with a `getCustomerInfo()` snapshot so returning subscribers are segmented correctly
118
+ * from their first event of the session. Returns the tier it resolved.
119
+ */
120
+ syncPlanTier(customerInfo: RevenueCatCustomerInfoLike | undefined): PlanTier;
121
+ }
122
+
123
+ /**
124
+ * Create a bridge bound to one sink and one entitlement. Pure, React-free, and safe to create at
125
+ * module scope next to the paywall.
126
+ */
127
+ export const createRevenueCatBridge = (config: RevenueCatBridgeConfig): RevenueCatBridge => {
128
+ const { analytics, entitlementId } = config;
129
+ const writeTier = config.writePlanTier !== false;
130
+
131
+ /**
132
+ * Send one event. Swallows a throwing sink AND a rejected promise from an awaitable one, so an
133
+ * analytics failure is never visible at the paywall and never becomes an unhandled rejection.
134
+ */
135
+ const emit = (event: string, props: Record<string, unknown>): void => {
136
+ try {
137
+ const result = analytics.track(event, { ...config.commonProps, ...props });
138
+ if (result && typeof (result as Promise<boolean>).catch === "function") {
139
+ void (result as Promise<boolean>).catch(() => {});
140
+ }
141
+ } catch {
142
+ /* analytics must never break a purchase */
143
+ }
144
+ };
145
+
146
+ const syncPlanTier = (customerInfo: RevenueCatCustomerInfoLike | undefined): PlanTier => {
147
+ const tier = resolvePlanTier(customerInfo, entitlementId);
148
+ if (!writeTier) return tier;
149
+ try {
150
+ analytics.setUserContext?.({ extra: { [PLAN_TIER_CONTEXT_KEY]: tier } });
151
+ } catch {
152
+ /* a sink without a usable context writer is not a purchase failure */
153
+ }
154
+ return tier;
155
+ };
156
+
157
+ const paywallShown = (offering?: RevenueCatOfferingLike, props?: PurchaseProps): void => {
158
+ const shown: Record<string, unknown> = {
159
+ packages_count: offering?.availablePackages?.length ?? 0,
160
+ };
161
+ if (typeof offering?.identifier === "string") shown.offering_id = offering.identifier;
162
+ emit(WIRE_PURCHASE_EVENTS.paywallShown, { ...shown, ...props });
163
+ };
164
+
165
+ const checkoutStarted = (pkg: RevenueCatPackageLike, props?: PurchaseProps): void => {
166
+ emit(WIRE_PURCHASE_EVENTS.checkoutStarted, { ...describePackage(pkg), ...props });
167
+ };
168
+
169
+ const purchaseCompleted = (
170
+ customerInfo: RevenueCatCustomerInfoLike | undefined,
171
+ pkg?: RevenueCatPackageLike,
172
+ props?: PurchaseProps,
173
+ ): boolean => {
174
+ const tier = syncPlanTier(customerInfo);
175
+ const entitled = tier !== "free";
176
+ if (entitled) {
177
+ emit(WIRE_PURCHASE_EVENTS.purchased, {
178
+ ...describePackage(pkg),
179
+ ...describeEntitlement(customerInfo, entitlementId),
180
+ ...props,
181
+ });
182
+ return true;
183
+ }
184
+ // The store said yes but the entitlement is not active: a product/entitlement mapping problem,
185
+ // the single most common silent paywall defect. Report it rather than returning quietly.
186
+ emit(WIRE_PURCHASE_EVENTS.purchaseFailed, {
187
+ ...describePackage(pkg),
188
+ reason: "not_entitled",
189
+ code: "unknown",
190
+ entitlement: entitlementId,
191
+ ...props,
192
+ });
193
+ return false;
194
+ };
195
+
196
+ const purchaseFailed = (
197
+ error: unknown,
198
+ pkg?: RevenueCatPackageLike,
199
+ props?: PurchaseProps,
200
+ ): void => {
201
+ emit(WIRE_PURCHASE_EVENTS.purchaseFailed, {
202
+ ...describePackage(pkg),
203
+ ...describeFailure(error),
204
+ ...props,
205
+ });
206
+ };
207
+
208
+ const purchasesRestored = (
209
+ customerInfo: RevenueCatCustomerInfoLike | undefined,
210
+ props?: PurchaseProps,
211
+ ): boolean => {
212
+ const tier = syncPlanTier(customerInfo);
213
+ emit(WIRE_PURCHASE_EVENTS.restored, {
214
+ ...describeEntitlement(customerInfo, entitlementId),
215
+ ...props,
216
+ });
217
+ return tier !== "free";
218
+ };
219
+
220
+ return { paywallShown, checkoutStarted, purchaseCompleted, purchaseFailed, purchasesRestored, syncPlanTier };
221
+ };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * types - STRUCTURAL mirrors of the `react-native-purchases` shapes this adapter READS.
3
+ *
4
+ * WHY structural and not an import: `react-native-purchases` is a NATIVE module. Importing it
5
+ * (even as an optional peer) would put a native rebuild in the kit's dependency story, which the
6
+ * kit's hard rule forbids. Every field below is a subset of the real 9.6.x typings
7
+ * (`@revenuecat/purchases-typescript-internal`: `CustomerInfo`, `PurchasesEntitlementInfo`,
8
+ * `PurchasesPackage`, `PurchasesStoreProduct`, `PurchasesError`), declared as plain `string` /
9
+ * `number` / `boolean` so a host can pass its REAL RevenueCat objects straight in: RevenueCat's
10
+ * `Store`, `PACKAGE_TYPE`, `PERIOD_TYPE` and `PURCHASES_ERROR_CODE` are all STRING enums, and a
11
+ * string enum member is assignable to `string`.
12
+ *
13
+ * The adapter only ever reads. It never constructs a RevenueCat object and never calls the SDK.
14
+ */
15
+
16
+ /** One entitlement as RevenueCat reports it (subset of `PurchasesEntitlementInfo`). */
17
+ export interface RevenueCatEntitlementLike {
18
+ /** The entitlement identifier configured in the RevenueCat dashboard, e.g. `"pro"`. */
19
+ readonly identifier: string;
20
+ /** True while the customer has access. */
21
+ readonly isActive: boolean;
22
+ /** `"NORMAL" | "INTRO" | "TRIAL" | "PREPAID"` on the real type; compared case-insensitively. */
23
+ readonly periodType?: string;
24
+ /** True when the underlying subscription renews at the end of the period. */
25
+ readonly willRenew?: boolean;
26
+ /** The store the entitlement was unlocked from, e.g. `"APP_STORE"`. */
27
+ readonly store?: string;
28
+ /** The product id that unlocked the entitlement. */
29
+ readonly productIdentifier?: string;
30
+ /** False when the purchase is a production one. */
31
+ readonly isSandbox?: boolean;
32
+ }
33
+
34
+ /** The customer snapshot RevenueCat returns from a purchase, a restore, or `getCustomerInfo`. */
35
+ export interface RevenueCatCustomerInfoLike {
36
+ readonly entitlements: {
37
+ /** Active entitlements keyed by identifier. Missing key means "not entitled". */
38
+ readonly active: { readonly [key: string]: RevenueCatEntitlementLike | undefined };
39
+ };
40
+ /** Product ids of the customer's active subscriptions. */
41
+ readonly activeSubscriptions?: readonly string[];
42
+ }
43
+
44
+ /** The purchasable product on a package (subset of `PurchasesStoreProduct`). */
45
+ export interface RevenueCatProductLike {
46
+ readonly identifier: string;
47
+ /** Price in the local currency, as a number. */
48
+ readonly price?: number;
49
+ /** ISO currency code of `price`, e.g. `"EUR"`. */
50
+ readonly currencyCode?: string;
51
+ }
52
+
53
+ /** One offering package (subset of `PurchasesPackage`). */
54
+ export interface RevenueCatPackageLike {
55
+ readonly identifier: string;
56
+ /** `"ANNUAL" | "MONTHLY" | ...` on the real type. */
57
+ readonly packageType?: string;
58
+ readonly product: RevenueCatProductLike;
59
+ /** The offering this package belongs to. */
60
+ readonly offeringIdentifier?: string;
61
+ }
62
+
63
+ /** The current offering a paywall renders (subset of `PurchasesOffering`). */
64
+ export interface RevenueCatOfferingLike {
65
+ readonly identifier?: string;
66
+ readonly availablePackages?: readonly RevenueCatPackageLike[];
67
+ }
68
+
69
+ /** The rejected value of a RevenueCat purchase call (subset of `PurchasesError`). */
70
+ export interface RevenueCatErrorLike {
71
+ /** `PURCHASES_ERROR_CODE`, a STRING enum; `"1"` is `PURCHASE_CANCELLED_ERROR`. */
72
+ readonly code?: string;
73
+ readonly message?: string;
74
+ /** Deprecated on the real type but still populated by 9.6.x. */
75
+ readonly userCancelled?: boolean | null;
76
+ }
77
+
78
+ /**
79
+ * The Wire sink the bridge reports through. Deliberately STRUCTURAL so BOTH kit surfaces satisfy
80
+ * it with no adapter of their own:
81
+ * - `createAnalytics(...)` (offline-first queue) matches `track` + `setUserContext`;
82
+ * - `createWireActivation(...)` (awaitable `wire.track`) matches `track`.
83
+ *
84
+ * Both auto-mint and stamp `user_context.device_key` on every event, which is the join key this
85
+ * adapter's whole design rests on (see `joinKey.md` in the module docs / the README section).
86
+ */
87
+ export interface RevenueCatSink {
88
+ /** Report an in-app event. Returns `void` (queued) or `Promise<boolean>` (awaited POST). */
89
+ track(event: string, props?: Record<string, unknown>): void | Promise<boolean>;
90
+ /**
91
+ * Optional: update the bound user context. Present on `createAnalytics`, absent on
92
+ * `createWireActivation` (its config is captured immutably), so entitlement sync is a no-op there.
93
+ */
94
+ setUserContext?(partial: { extra?: Record<string, string | number | boolean> }): void;
95
+ }