@chipmobilesdk/rn-payment 0.4.0 → 0.5.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,247 +1,220 @@
1
- /**
2
- * The persisted snapshot: schema, versioning, and migration.
3
- *
4
- * What is stored here is **exhaustively** what the package keeps on the device,
5
- * and the README repeats this list so an app owner can complete a Data safety
6
- * declaration accurately (FR-056, POL-002).
7
- *
8
- * Deliberately absent: payment details, card data, billing address, user
9
- * identity, raw receipts, verification payloads, and backend tokens.
10
- */
11
-
12
- import type {
13
- EntitlementKey,
14
- ProductKey,
15
- ProductType,
16
- } from '../config/types';
17
-
18
- export const SNAPSHOT_SCHEMA_VERSION = 1;
19
-
20
- /** Where a transaction stands with the store. */
21
- export type PersistedPurchaseState =
22
- | 'pending'
23
- | 'purchased'
24
- | 'finalized'
25
- | 'consumed'
26
- | 'revoked';
27
-
28
- /**
29
- * Where a transaction stands in the finalization queue.
30
- *
31
- * `queued` is the load-bearing value: it is written **before** the store call is
32
- * attempted, so an app killed mid-finalize leaves a recoverable record rather
33
- * than a paid purchase that Google will auto-refund (FR-041, FR-042).
34
- */
35
- export type PersistedFinalizeState =
36
- | 'not_required'
37
- | 'queued'
38
- | 'in_flight'
39
- | 'done'
40
- | 'failed';
41
-
42
- /** Whether the app's backend has ruled on this transaction (FR-072a). */
43
- export type PersistedValidationState =
44
- | 'not_required'
45
- | 'approved'
46
- | 'rejected'
47
- | 'queued_for_revalidation';
48
-
49
- export interface PersistedPurchase {
50
- transactionId: string;
51
- productKey: ProductKey;
52
- productType: ProductType;
53
- token: string;
54
- state: PersistedPurchaseState;
55
- purchasedAt: number;
56
- finalizeState: PersistedFinalizeState;
57
- /** Attempts consumed. Carried across sessions so retry stays bounded per session. */
58
- attempts: number;
59
- validationState: PersistedValidationState;
60
- expiresAt?: number;
61
- willAutoRenew?: boolean;
62
- }
63
-
64
- export interface PersistedEntitlement {
65
- sources: ProductKey[];
66
- expiresAt?: number;
67
- willAutoRenew?: boolean;
68
- isCancelledButValid?: boolean;
69
- }
70
-
71
- export interface PersistedSnapshot {
72
- schemaVersion: number;
73
- /** Opaque partition key. Stored only to scope this record (FR-057a). */
74
- scopeToken?: string;
75
- entitlements: Record<EntitlementKey, PersistedEntitlement>;
76
- purchases: PersistedPurchase[];
77
- lastSyncedAt?: number;
78
- }
79
-
80
- export function emptySnapshot(scopeToken?: string): PersistedSnapshot {
81
- return {
82
- schemaVersion: SNAPSHOT_SCHEMA_VERSION,
83
- scopeToken,
84
- entitlements: {},
85
- purchases: [],
86
- lastSyncedAt: undefined,
87
- };
88
- }
89
-
90
- /**
91
- * A migration from one schema version to the next.
92
- *
93
- * Kept as an ordered chain rather than a switch so each step stays small and
94
- * independently testable, and so a device several versions behind is carried
95
- * forward one hop at a time (NFR-009).
96
- */
97
- export interface SnapshotMigration {
98
- from: number;
99
- to: number;
100
- migrate(input: Record<string, unknown>): Record<string, unknown>;
101
- }
102
-
103
- /**
104
- * Registered migrations.
105
- *
106
- * Empty at v1 by definition — there is no earlier shipped version to migrate
107
- * from. The chain exists now so the first real migration is a one-entry change
108
- * rather than a refactor under time pressure.
109
- */
110
- export const SNAPSHOT_MIGRATIONS: readonly SnapshotMigration[] = [];
111
-
112
- export type MigrationOutcome =
113
- | { ok: true; snapshot: PersistedSnapshot; migrated: boolean }
114
- /**
115
- * Unreadable records are discarded, not repaired.
116
- *
117
- * Safe because discarding yields `unknown`, never a false `not_entitled`
118
- * (FR-055a), and the next sync rebuilds from the store — the authoritative
119
- * source anyway. Guessing at a corrupt record risks granting or withholding
120
- * wrongly, which is worse than a short `unknown` window.
121
- */
122
- | { ok: false; reason: 'unreadable' | 'unsupported_version' };
123
-
124
- export function migrateSnapshot(raw: unknown): MigrationOutcome {
125
- if (typeof raw !== 'object' || raw === null) {
126
- return { ok: false, reason: 'unreadable' };
127
- }
128
-
129
- let working = { ...(raw as Record<string, unknown>) };
130
- const version = working.schemaVersion;
131
-
132
- if (typeof version !== 'number' || !Number.isInteger(version) || version < 1) {
133
- return { ok: false, reason: 'unreadable' };
134
- }
135
-
136
- if (version > SNAPSHOT_SCHEMA_VERSION) {
137
- // A newer app version wrote this. Downgrading cannot know what was added,
138
- // so refuse rather than silently drop fields.
139
- return { ok: false, reason: 'unsupported_version' };
140
- }
141
-
142
- let current = version;
143
- let migrated = false;
144
- while (current < SNAPSHOT_SCHEMA_VERSION) {
145
- const step = SNAPSHOT_MIGRATIONS.find(m => m.from === current);
146
- if (!step) {
147
- return { ok: false, reason: 'unsupported_version' };
148
- }
149
- working = step.migrate(working);
150
- current = step.to;
151
- migrated = true;
152
- }
153
-
154
- const snapshot = coerceSnapshot(working);
155
- return snapshot
156
- ? { ok: true, snapshot, migrated }
157
- : { ok: false, reason: 'unreadable' };
158
- }
159
-
160
- /**
161
- * Validates shape after migration.
162
- *
163
- * Runs even on a non-migrated record because the bytes came off a device where
164
- * anything could have happened to them — a partial write, a rooted user, a
165
- * truncated file.
166
- */
167
- function coerceSnapshot(input: Record<string, unknown>): PersistedSnapshot | null {
168
- const entitlements = input.entitlements;
169
- const purchases = input.purchases;
170
-
171
- if (typeof entitlements !== 'object' || entitlements === null) {
172
- return null;
173
- }
174
- if (!Array.isArray(purchases)) {
175
- return null;
176
- }
177
-
178
- const lastSyncedAt =
179
- typeof input.lastSyncedAt === 'number' ? input.lastSyncedAt : undefined;
180
- const scopeToken =
181
- typeof input.scopeToken === 'string' ? input.scopeToken : undefined;
182
-
183
- return {
184
- schemaVersion: SNAPSHOT_SCHEMA_VERSION,
185
- scopeToken,
186
- entitlements: entitlements as Record<EntitlementKey, PersistedEntitlement>,
187
- purchases: purchases as PersistedPurchase[],
188
- lastSyncedAt,
189
- };
190
- }
191
-
192
- /**
193
- * Decides a purchase's finalization state from the store's view plus what we
194
- * already knew.
195
- *
196
- * Shared by the sync path and the purchase path so they cannot drift — and they
197
- * must not, because a disagreement here is how a paid transaction goes
198
- * unacknowledged and gets auto-refunded three days later.
199
- *
200
- * The subtle case is a **pending purchase that settles**. Its record was written
201
- * with `not_required`, which was correct while the money had not arrived. Once the
202
- * store reports it `purchased`, that value must be re-evaluated rather than
203
- * preserved — otherwise the transaction is never queued, never acknowledged, and
204
- * Google revokes it. Progress is preserved only for a transaction that was
205
- * already in the queue.
206
- */
207
- export function resolveFinalizeState(
208
- incoming: { state: 'purchased' | 'pending' | 'unspecified'; isAcknowledged: boolean },
209
- existing: { finalizeState: PersistedFinalizeState } | undefined,
210
- ): PersistedFinalizeState {
211
- if (incoming.state === 'pending') {
212
- // Nothing to settle: there is no entitlement to report as delivered.
213
- return 'not_required';
214
- }
215
-
216
- // Already settled at the store, whatever we thought locally.
217
- if (incoming.isAcknowledged) {
218
- return 'done';
219
- }
220
-
221
- // Genuine in-queue progress is preserved, so a sync cannot reset an attempt
222
- // count or lose an in-flight finalize.
223
- if (
224
- existing &&
225
- existing.finalizeState !== 'not_required' &&
226
- existing.finalizeState !== 'done'
227
- ) {
228
- return existing.finalizeState;
229
- }
230
-
231
- // Covers both a brand-new purchase and a previously-pending one that just
232
- // settled. Either way it is now owed a finalize call.
233
- return 'queued';
234
- }
235
-
236
- /** Transactions still owed a finalize call, drained at startup and on foreground (FR-042). */
237
- export function pendingFinalizations(
238
- snapshot: PersistedSnapshot,
239
- ): PersistedPurchase[] {
240
- return snapshot.purchases.filter(
241
- p =>
242
- (p.finalizeState === 'queued' ||
243
- p.finalizeState === 'in_flight' ||
244
- p.finalizeState === 'failed') &&
245
- p.state !== 'revoked',
246
- );
247
- }
1
+ /**
2
+ * The persisted snapshot: schema, versioning, and migration.
3
+ *
4
+ * What is stored here is **exhaustively** what the package keeps on the device,
5
+ * and the README repeats this list so an app owner can complete a Data safety
6
+ * declaration accurately (FR-056, POL-002).
7
+ *
8
+ * Deliberately absent: payment details, card data, billing address, user
9
+ * identity, raw receipts, verification payloads, and backend tokens.
10
+ */
11
+
12
+ import type { EntitlementKey, ProductKey, ProductType } from '../config/types';
13
+
14
+ export const SNAPSHOT_SCHEMA_VERSION = 1;
15
+
16
+ /** Where a transaction stands with the store. */
17
+ export type PersistedPurchaseState = 'pending' | 'purchased' | 'finalized' | 'consumed' | 'revoked';
18
+
19
+ /**
20
+ * Where a transaction stands in the finalization queue.
21
+ *
22
+ * `queued` is the load-bearing value: it is written **before** the store call is
23
+ * attempted, so an app killed mid-finalize leaves a recoverable record rather
24
+ * than a paid purchase that Google will auto-refund (FR-041, FR-042).
25
+ */
26
+ export type PersistedFinalizeState = 'not_required' | 'queued' | 'in_flight' | 'done' | 'failed';
27
+
28
+ /** Whether the app's backend has ruled on this transaction (FR-072a). */
29
+ export type PersistedValidationState =
30
+ 'not_required' | 'approved' | 'rejected' | 'queued_for_revalidation';
31
+
32
+ export interface PersistedPurchase {
33
+ transactionId: string;
34
+ productKey: ProductKey;
35
+ productType: ProductType;
36
+ token: string;
37
+ state: PersistedPurchaseState;
38
+ purchasedAt: number;
39
+ finalizeState: PersistedFinalizeState;
40
+ /** Attempts consumed. Carried across sessions so retry stays bounded per session. */
41
+ attempts: number;
42
+ validationState: PersistedValidationState;
43
+ expiresAt?: number;
44
+ willAutoRenew?: boolean;
45
+ }
46
+
47
+ export interface PersistedEntitlement {
48
+ sources: ProductKey[];
49
+ expiresAt?: number;
50
+ willAutoRenew?: boolean;
51
+ isCancelledButValid?: boolean;
52
+ }
53
+
54
+ export interface PersistedSnapshot {
55
+ schemaVersion: number;
56
+ /** Opaque partition key. Stored only to scope this record (FR-057a). */
57
+ scopeToken?: string;
58
+ entitlements: Record<EntitlementKey, PersistedEntitlement>;
59
+ purchases: PersistedPurchase[];
60
+ lastSyncedAt?: number;
61
+ }
62
+
63
+ export function emptySnapshot(scopeToken?: string): PersistedSnapshot {
64
+ return {
65
+ schemaVersion: SNAPSHOT_SCHEMA_VERSION,
66
+ scopeToken,
67
+ entitlements: {},
68
+ purchases: [],
69
+ lastSyncedAt: undefined,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * A migration from one schema version to the next.
75
+ *
76
+ * Kept as an ordered chain rather than a switch so each step stays small and
77
+ * independently testable, and so a device several versions behind is carried
78
+ * forward one hop at a time (NFR-009).
79
+ */
80
+ export interface SnapshotMigration {
81
+ from: number;
82
+ to: number;
83
+ migrate(input: Record<string, unknown>): Record<string, unknown>;
84
+ }
85
+
86
+ /**
87
+ * Registered migrations.
88
+ *
89
+ * Empty at v1 by definition — there is no earlier shipped version to migrate
90
+ * from. The chain exists now so the first real migration is a one-entry change
91
+ * rather than a refactor under time pressure.
92
+ */
93
+ export const SNAPSHOT_MIGRATIONS: readonly SnapshotMigration[] = [];
94
+
95
+ export type MigrationOutcome =
96
+ | { ok: true; snapshot: PersistedSnapshot; migrated: boolean }
97
+ /**
98
+ * Unreadable records are discarded, not repaired.
99
+ *
100
+ * Safe because discarding yields `unknown`, never a false `not_entitled`
101
+ * (FR-055a), and the next sync rebuilds from the store — the authoritative
102
+ * source anyway. Guessing at a corrupt record risks granting or withholding
103
+ * wrongly, which is worse than a short `unknown` window.
104
+ */
105
+ | { ok: false; reason: 'unreadable' | 'unsupported_version' };
106
+
107
+ export function migrateSnapshot(raw: unknown): MigrationOutcome {
108
+ if (typeof raw !== 'object' || raw === null) {
109
+ return { ok: false, reason: 'unreadable' };
110
+ }
111
+
112
+ let working = { ...(raw as Record<string, unknown>) };
113
+ const version = working.schemaVersion;
114
+
115
+ if (typeof version !== 'number' || !Number.isInteger(version) || version < 1) {
116
+ return { ok: false, reason: 'unreadable' };
117
+ }
118
+
119
+ if (version > SNAPSHOT_SCHEMA_VERSION) {
120
+ // A newer app version wrote this. Downgrading cannot know what was added,
121
+ // so refuse rather than silently drop fields.
122
+ return { ok: false, reason: 'unsupported_version' };
123
+ }
124
+
125
+ let current = version;
126
+ let migrated = false;
127
+ while (current < SNAPSHOT_SCHEMA_VERSION) {
128
+ const step = SNAPSHOT_MIGRATIONS.find(m => m.from === current);
129
+ if (!step) {
130
+ return { ok: false, reason: 'unsupported_version' };
131
+ }
132
+ working = step.migrate(working);
133
+ current = step.to;
134
+ migrated = true;
135
+ }
136
+
137
+ const snapshot = coerceSnapshot(working);
138
+ return snapshot ? { ok: true, snapshot, migrated } : { ok: false, reason: 'unreadable' };
139
+ }
140
+
141
+ /**
142
+ * Validates shape after migration.
143
+ *
144
+ * Runs even on a non-migrated record because the bytes came off a device where
145
+ * anything could have happened to them — a partial write, a rooted user, a
146
+ * truncated file.
147
+ */
148
+ function coerceSnapshot(input: Record<string, unknown>): PersistedSnapshot | null {
149
+ const entitlements = input.entitlements;
150
+ const purchases = input.purchases;
151
+
152
+ if (typeof entitlements !== 'object' || entitlements === null) {
153
+ return null;
154
+ }
155
+ if (!Array.isArray(purchases)) {
156
+ return null;
157
+ }
158
+
159
+ const lastSyncedAt = typeof input.lastSyncedAt === 'number' ? input.lastSyncedAt : undefined;
160
+ const scopeToken = typeof input.scopeToken === 'string' ? input.scopeToken : undefined;
161
+
162
+ return {
163
+ schemaVersion: SNAPSHOT_SCHEMA_VERSION,
164
+ scopeToken,
165
+ entitlements: entitlements as Record<EntitlementKey, PersistedEntitlement>,
166
+ purchases: purchases as PersistedPurchase[],
167
+ lastSyncedAt,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Decides a purchase's finalization state from the store's view plus what we
173
+ * already knew.
174
+ *
175
+ * Shared by the sync path and the purchase path so they cannot drift — and they
176
+ * must not, because a disagreement here is how a paid transaction goes
177
+ * unacknowledged and gets auto-refunded three days later.
178
+ *
179
+ * The subtle case is a **pending purchase that settles**. Its record was written
180
+ * with `not_required`, which was correct while the money had not arrived. Once the
181
+ * store reports it `purchased`, that value must be re-evaluated rather than
182
+ * preserved — otherwise the transaction is never queued, never acknowledged, and
183
+ * Google revokes it. Progress is preserved only for a transaction that was
184
+ * already in the queue.
185
+ */
186
+ export function resolveFinalizeState(
187
+ incoming: { state: 'purchased' | 'pending' | 'unspecified'; isAcknowledged: boolean },
188
+ existing: { finalizeState: PersistedFinalizeState } | undefined,
189
+ ): PersistedFinalizeState {
190
+ if (incoming.state === 'pending') {
191
+ // Nothing to settle: there is no entitlement to report as delivered.
192
+ return 'not_required';
193
+ }
194
+
195
+ // Already settled at the store, whatever we thought locally.
196
+ if (incoming.isAcknowledged) {
197
+ return 'done';
198
+ }
199
+
200
+ // Genuine in-queue progress is preserved, so a sync cannot reset an attempt
201
+ // count or lose an in-flight finalize.
202
+ if (existing && existing.finalizeState !== 'not_required' && existing.finalizeState !== 'done') {
203
+ return existing.finalizeState;
204
+ }
205
+
206
+ // Covers both a brand-new purchase and a previously-pending one that just
207
+ // settled. Either way it is now owed a finalize call.
208
+ return 'queued';
209
+ }
210
+
211
+ /** Transactions still owed a finalize call, drained at startup and on foreground (FR-042). */
212
+ export function pendingFinalizations(snapshot: PersistedSnapshot): PersistedPurchase[] {
213
+ return snapshot.purchases.filter(
214
+ p =>
215
+ (p.finalizeState === 'queued' ||
216
+ p.finalizeState === 'in_flight' ||
217
+ p.finalizeState === 'failed') &&
218
+ p.state !== 'revoked',
219
+ );
220
+ }
@@ -1,79 +1,90 @@
1
- /**
2
- * React integration.
3
- *
4
- * The provider owns the engine's lifecycle so an app does not have to remember to
5
- * clean up. It renders nothing of its own — no paywall, no price table, no
6
- * button. Every surface belongs to the app (UX-001).
7
- */
8
-
9
- import React, {
10
- createContext,
11
- useContext,
12
- useEffect,
13
- useMemo,
14
- useRef,
15
- type PropsWithChildren,
16
- } from 'react';
17
- import { createEngine, type EngineDeps, type PaymentEngine } from '../engine/engine';
18
- import type { PaymentConfig } from '../config/types';
19
-
20
- const PaymentContext = createContext<PaymentEngine | null>(null);
21
-
22
- export interface PaymentProviderProps {
23
- config: PaymentConfig;
24
- /**
25
- * Provider injection.
26
- *
27
- * Required until the real adapter ships; also how a test or the demo app
28
- * supplies the fake store. Keeping it explicit rather than defaulting to a
29
- * lazily-required binding means a missing native module is a clear
30
- * configuration error instead of a mysterious runtime failure.
31
- */
32
- deps: EngineDeps;
33
- /** Skip automatic initialization when the app wants to control the moment. */
34
- autoInitialize?: boolean;
35
- }
36
-
37
- export function PaymentProvider({
38
- config,
39
- deps,
40
- autoInitialize = true,
41
- children,
42
- }: PropsWithChildren<PaymentProviderProps>): React.ReactElement {
43
- // Built once per config identity. Rebuilding on every render would reconnect
44
- // to the billing service repeatedly, which FR-018 exists to prevent.
45
- const engine = useMemo(() => createEngine(config, deps), [config, deps]);
46
- const engineRef = useRef<PaymentEngine>(engine);
47
- engineRef.current = engine;
48
-
49
- useEffect(() => {
50
- if (autoInitialize) {
51
- engine.initialize().catch(() => undefined);
52
- }
53
- // Cleanup on unmount is the whole reason the provider owns the engine:
54
- // after it, no event fires and no timer survives (FR-082).
55
- return () => engine.cleanup();
56
- }, [engine, autoInitialize]);
57
-
58
- return (
59
- <PaymentContext.Provider value={engine}>{children}</PaymentContext.Provider>
60
- );
61
- }
62
-
63
- /**
64
- * The engine from context.
65
- *
66
- * Throws when used outside a provider, deliberately: returning null would push a
67
- * null check into every consumer and let a genuine wiring mistake read as "no
68
- * entitlement" — the exact conflation this package exists to prevent.
69
- */
70
- export function usePaymentEngine(): PaymentEngine {
71
- const engine = useContext(PaymentContext);
72
- if (!engine) {
73
- throw new Error(
74
- 'usePaymentEngine must be used inside a <PaymentProvider>. Wrap your app ' +
75
- 'or the screen that reads entitlements.',
76
- );
77
- }
78
- return engine;
79
- }
1
+ /**
2
+ * React integration.
3
+ *
4
+ * The provider owns the engine's lifecycle so an app does not have to remember to
5
+ * clean up. It renders nothing of its own — no paywall, no price table, no
6
+ * button. Every surface belongs to the app (UX-001).
7
+ */
8
+
9
+ import React, {
10
+ createContext,
11
+ useContext,
12
+ useEffect,
13
+ useMemo,
14
+ useRef,
15
+ type PropsWithChildren,
16
+ } from 'react';
17
+ import { createEngine, type EngineDeps, type PaymentEngine } from '../engine/engine';
18
+ import { syncOnForeground as watchForeground } from '../engine/foreground';
19
+ import type { PaymentConfig } from '../config/types';
20
+
21
+ const PaymentContext = createContext<PaymentEngine | null>(null);
22
+
23
+ export interface PaymentProviderProps {
24
+ config: PaymentConfig;
25
+ /**
26
+ * Provider injection.
27
+ *
28
+ * Required until the real adapter ships; also how a test or the demo app
29
+ * supplies the fake store. Keeping it explicit rather than defaulting to a
30
+ * lazily-required binding means a missing native module is a clear
31
+ * configuration error instead of a mysterious runtime failure.
32
+ */
33
+ deps: EngineDeps;
34
+ /** Skip automatic initialization when the app wants to control the moment. */
35
+ autoInitialize?: boolean;
36
+ /**
37
+ * Call `handleForeground()` each time the app returns from the background, through
38
+ * rn-core's shared lifecycle port. Off by default: what counts as a foreground
39
+ * return is the app's decision (FR-059).
40
+ */
41
+ syncOnForeground?: boolean;
42
+ }
43
+
44
+ export function PaymentProvider({
45
+ config,
46
+ deps,
47
+ autoInitialize = true,
48
+ syncOnForeground = false,
49
+ children,
50
+ }: PropsWithChildren<PaymentProviderProps>): React.ReactElement {
51
+ // Built once per config identity. Rebuilding on every render would reconnect
52
+ // to the billing service repeatedly, which FR-018 exists to prevent.
53
+ const engine = useMemo(() => createEngine(config, deps), [config, deps]);
54
+ const engineRef = useRef<PaymentEngine>(engine);
55
+ engineRef.current = engine;
56
+
57
+ useEffect(() => {
58
+ if (autoInitialize) {
59
+ engine.initialize().catch(() => undefined);
60
+ }
61
+ // Cleanup on unmount is the whole reason the provider owns the engine:
62
+ // after it, no event fires and no timer survives (FR-082).
63
+ return () => engine.cleanup();
64
+ }, [engine, autoInitialize]);
65
+
66
+ useEffect(
67
+ () => (syncOnForeground ? watchForeground(engine) : undefined),
68
+ [engine, syncOnForeground],
69
+ );
70
+
71
+ return <PaymentContext.Provider value={engine}>{children}</PaymentContext.Provider>;
72
+ }
73
+
74
+ /**
75
+ * The engine from context.
76
+ *
77
+ * Throws when used outside a provider, deliberately: returning null would push a
78
+ * null check into every consumer and let a genuine wiring mistake read as "no
79
+ * entitlement" — the exact conflation this package exists to prevent.
80
+ */
81
+ export function usePaymentEngine(): PaymentEngine {
82
+ const engine = useContext(PaymentContext);
83
+ if (!engine) {
84
+ throw new Error(
85
+ 'usePaymentEngine must be used inside a <PaymentProvider>. Wrap your app ' +
86
+ 'or the screen that reads entitlements.',
87
+ );
88
+ }
89
+ return engine;
90
+ }