@chipmobilesdk/rn-payment 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +112 -0
- package/README.md +760 -2
- package/package.json +54 -6
- package/skills/sdk-rn-payment/SKILL.md +137 -0
- package/src/adapter/createStoreProvider.ts +117 -0
- package/src/adapter/iap-adapter.ts +516 -0
- package/src/adapter/port.ts +186 -0
- package/src/config/types.ts +255 -0
- package/src/config/validate.ts +232 -0
- package/src/core/mutex.ts +128 -0
- package/src/core/retry.ts +133 -0
- package/src/core/timers.ts +249 -0
- package/src/diagnostics/errors.ts +110 -0
- package/src/diagnostics/events.ts +257 -0
- package/src/diagnostics/messageKeys.ts +95 -0
- package/src/engine/engine.ts +854 -0
- package/src/engine/finalize.ts +298 -0
- package/src/engine/products.ts +259 -0
- package/src/engine/purchase.ts +495 -0
- package/src/engine/subscriptions.ts +157 -0
- package/src/entitlements/aggregate.ts +225 -0
- package/src/entitlements/mapping.ts +67 -0
- package/src/entitlements/policy.ts +129 -0
- package/src/index.ts +168 -0
- package/src/persistence/mmkv-store.ts +150 -0
- package/src/persistence/snapshot.ts +247 -0
- package/src/react/PaymentProvider.tsx +79 -0
- package/src/react/hooks.ts +238 -0
- package/src/testing/fakeStore.ts +341 -0
- package/src/testing/index.ts +10 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MMKV-backed snapshot store.
|
|
3
|
+
*
|
|
4
|
+
* Every MMKV import is confined to this file, so the rest of the package stays
|
|
5
|
+
* testable without a native module.
|
|
6
|
+
*
|
|
7
|
+
* MMKV is chosen because it is **synchronous**, which is what lets
|
|
8
|
+
* `getEntitlement` be an in-memory read with no I/O on render paths (PERF-001).
|
|
9
|
+
* An async store would force either an async entitlement read or a hidden
|
|
10
|
+
* hydration race on every call.
|
|
11
|
+
*
|
|
12
|
+
* Records are partitioned by cache-scope token (FR-057a). The token is a
|
|
13
|
+
* partition key and nothing more: never interpreted, never transmitted, never
|
|
14
|
+
* attached to a transaction.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
emptySnapshot,
|
|
19
|
+
migrateSnapshot,
|
|
20
|
+
type PersistedSnapshot,
|
|
21
|
+
} from './snapshot';
|
|
22
|
+
|
|
23
|
+
const INSTANCE_ID = 'chipmobilesdk.payment';
|
|
24
|
+
const KEY_PREFIX = 'snapshot';
|
|
25
|
+
|
|
26
|
+
interface MmkvLike {
|
|
27
|
+
getString(key: string): string | undefined;
|
|
28
|
+
set(key: string, value: string): void;
|
|
29
|
+
delete(key: string): void;
|
|
30
|
+
clearAll(): void;
|
|
31
|
+
getAllKeys(): string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SnapshotStore {
|
|
35
|
+
load(scopeToken?: string): PersistedSnapshot;
|
|
36
|
+
save(snapshot: PersistedSnapshot): void;
|
|
37
|
+
/** Clears every scope — for logout and data-deletion requests (FR-057). */
|
|
38
|
+
clearAll(): void;
|
|
39
|
+
/** Whether writes actually persist. False means the memory fallback is active. */
|
|
40
|
+
isDurable(): boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function scopeKey(scopeToken?: string): string {
|
|
44
|
+
// An explicit sentinel rather than an empty segment, so the unscoped record
|
|
45
|
+
// can never collide with a scope whose token happens to be empty.
|
|
46
|
+
return `${KEY_PREFIX}:${scopeToken ?? '__default__'}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** In-memory fallback, used when the native module is absent. */
|
|
50
|
+
function createMemoryBacking(): MmkvLike {
|
|
51
|
+
const map = new Map<string, string>();
|
|
52
|
+
return {
|
|
53
|
+
getString: key => map.get(key),
|
|
54
|
+
set(key, value) {
|
|
55
|
+
map.set(key, value);
|
|
56
|
+
},
|
|
57
|
+
delete(key) {
|
|
58
|
+
map.delete(key);
|
|
59
|
+
},
|
|
60
|
+
clearAll: () => map.clear(),
|
|
61
|
+
getAllKeys: () => [...map.keys()],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveBacking(): { backing: MmkvLike; durable: boolean } {
|
|
66
|
+
try {
|
|
67
|
+
// Required lazily so the package loads where the native module is absent.
|
|
68
|
+
const mod = require('react-native-mmkv') as {
|
|
69
|
+
MMKV: new (config?: { id?: string }) => MmkvLike;
|
|
70
|
+
};
|
|
71
|
+
return { backing: new mod.MMKV({ id: INSTANCE_ID }), durable: true };
|
|
72
|
+
} catch {
|
|
73
|
+
return { backing: createMemoryBacking(), durable: false };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Creates the store.
|
|
79
|
+
*
|
|
80
|
+
* The memory fallback is a **documented degradation**, not a silent one: without
|
|
81
|
+
* MMKV the snapshot does not survive a cold start, so entitlements read `unknown`
|
|
82
|
+
* until the first sync. That is safe by construction — `unknown`, never
|
|
83
|
+
* `not_entitled` (FR-047) — and FR-059's startup sync repairs it. `isDurable()`
|
|
84
|
+
* lets the engine warn, so the condition is visible rather than mysterious.
|
|
85
|
+
*/
|
|
86
|
+
export function createSnapshotStore(
|
|
87
|
+
injected?: { backing: MmkvLike; durable: boolean },
|
|
88
|
+
): SnapshotStore {
|
|
89
|
+
const { backing, durable } = injected ?? resolveBacking();
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
load(scopeToken?: string): PersistedSnapshot {
|
|
93
|
+
const raw = backing.getString(scopeKey(scopeToken));
|
|
94
|
+
if (raw === undefined) {
|
|
95
|
+
return emptySnapshot(scopeToken);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let parsed: unknown;
|
|
99
|
+
try {
|
|
100
|
+
parsed = JSON.parse(raw);
|
|
101
|
+
} catch {
|
|
102
|
+
// Corrupt JSON is discarded rather than repaired: the store is the
|
|
103
|
+
// authoritative source and the next sync rebuilds from it.
|
|
104
|
+
return emptySnapshot(scopeToken);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const outcome = migrateSnapshot(parsed);
|
|
108
|
+
if (!outcome.ok) {
|
|
109
|
+
return emptySnapshot(scopeToken);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// A record whose stored token disagrees with the requested scope must not
|
|
113
|
+
// be served: that is precisely the account-switch inheritance FR-057a
|
|
114
|
+
// exists to prevent.
|
|
115
|
+
if (outcome.snapshot.scopeToken !== scopeToken) {
|
|
116
|
+
return emptySnapshot(scopeToken);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return outcome.snapshot;
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
save(snapshot: PersistedSnapshot): void {
|
|
123
|
+
try {
|
|
124
|
+
backing.set(scopeKey(snapshot.scopeToken), JSON.stringify(snapshot));
|
|
125
|
+
} catch {
|
|
126
|
+
// A failed write must never take down a purchase flow. The in-memory
|
|
127
|
+
// state stays correct for this session and the next sync re-derives it.
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
clearAll(): void {
|
|
132
|
+
try {
|
|
133
|
+
for (const key of backing.getAllKeys()) {
|
|
134
|
+
if (key.startsWith(`${KEY_PREFIX}:`)) {
|
|
135
|
+
backing.delete(key);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// Best effort; a failure here must not throw into a logout flow.
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
isDurable: () => durable,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Test seam for driving the store without the native module. */
|
|
148
|
+
export function createMemorySnapshotStore(): SnapshotStore {
|
|
149
|
+
return createSnapshotStore({ backing: createMemoryBacking(), durable: false });
|
|
150
|
+
}
|
|
@@ -0,0 +1,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 {
|
|
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
|
+
}
|
|
@@ -0,0 +1,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 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
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hooks over the engine.
|
|
3
|
+
*
|
|
4
|
+
* Each subscribes on mount and unsubscribes on unmount, so repeated mount/unmount
|
|
5
|
+
* leaves no duplicate subscription and no surviving listener (FR-006, PERF-002).
|
|
6
|
+
*
|
|
7
|
+
* Reads are synchronous: the engine holds entitlements in memory, so a hook never
|
|
8
|
+
* returns a loading placeholder for an entitlement it already knows (PERF-001).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
12
|
+
import { usePaymentEngine } from './PaymentProvider';
|
|
13
|
+
import type {
|
|
14
|
+
EntitlementKey,
|
|
15
|
+
FinalizeResult,
|
|
16
|
+
PaymentStatus,
|
|
17
|
+
PurchaseOptions,
|
|
18
|
+
PurchaseResult,
|
|
19
|
+
TransactionRef,
|
|
20
|
+
} from '../config/types';
|
|
21
|
+
import type {
|
|
22
|
+
EntitlementMap,
|
|
23
|
+
EntitlementState,
|
|
24
|
+
} from '../entitlements/aggregate';
|
|
25
|
+
import type { PaymentError } from '../diagnostics/errors';
|
|
26
|
+
import type { MissingProduct, ProductInfo } from '../engine/products';
|
|
27
|
+
import type { RestoreOutcome, SyncOutcome } from '../engine/engine';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One entitlement, re-rendering only when that one changes.
|
|
31
|
+
*
|
|
32
|
+
* The returned `status` is `unknown` until the first sync completes. That is the
|
|
33
|
+
* point: an app must be able to tell "not yet known" from "not entitled" so a
|
|
34
|
+
* paying user never sees the unpaid experience flash (FR-047).
|
|
35
|
+
*/
|
|
36
|
+
export function useEntitlement(key: EntitlementKey): EntitlementState {
|
|
37
|
+
const engine = usePaymentEngine();
|
|
38
|
+
const [state, setState] = useState<EntitlementState>(() =>
|
|
39
|
+
engine.getEntitlement(key),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
// Re-read on subscribe: the entitlement may have changed between the initial
|
|
44
|
+
// render and this effect.
|
|
45
|
+
setState(engine.getEntitlement(key));
|
|
46
|
+
return engine.subscribeEntitlements(map => {
|
|
47
|
+
const next = map[key];
|
|
48
|
+
if (next) {
|
|
49
|
+
setState(next);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}, [engine, key]);
|
|
53
|
+
|
|
54
|
+
return state;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Every entitlement at once, for a screen that shows overall plan state. */
|
|
58
|
+
export function useEntitlements(): EntitlementMap {
|
|
59
|
+
const engine = usePaymentEngine();
|
|
60
|
+
const [map, setMap] = useState<EntitlementMap>(() =>
|
|
61
|
+
engine.getAllEntitlements(),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
setMap(engine.getAllEntitlements());
|
|
66
|
+
return engine.subscribeEntitlements(setMap);
|
|
67
|
+
}, [engine]);
|
|
68
|
+
|
|
69
|
+
return map;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Package status, so an app can tell "cannot sell here" from "not paid" (FR-022). */
|
|
73
|
+
export function usePaymentStatus(): PaymentStatus {
|
|
74
|
+
const engine = usePaymentEngine();
|
|
75
|
+
const [status, setStatus] = useState<PaymentStatus>(() => engine.getStatus());
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
setStatus(engine.getStatus());
|
|
79
|
+
return engine.subscribeStatus(setStatus);
|
|
80
|
+
}, [engine]);
|
|
81
|
+
|
|
82
|
+
return status;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface UsePurchaseResult {
|
|
86
|
+
purchase: (
|
|
87
|
+
productKey: EntitlementKey,
|
|
88
|
+
options?: PurchaseOptions,
|
|
89
|
+
) => Promise<PurchaseResult>;
|
|
90
|
+
consume: (ref: TransactionRef) => Promise<FinalizeResult>;
|
|
91
|
+
isPurchasing: boolean;
|
|
92
|
+
/** The last outcome, so a screen can render cancellation differently from failure. */
|
|
93
|
+
lastResult: PurchaseResult | undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Starting a purchase.
|
|
98
|
+
*
|
|
99
|
+
* `lastResult` is the full typed union rather than a boolean, because a screen has
|
|
100
|
+
* to treat `user_cancelled` differently from `store_error` — showing an error
|
|
101
|
+
* banner to someone who simply changed their mind is the most common paywall bug.
|
|
102
|
+
*/
|
|
103
|
+
export function usePurchase(): UsePurchaseResult {
|
|
104
|
+
const engine = usePaymentEngine();
|
|
105
|
+
const [isPurchasing, setIsPurchasing] = useState(false);
|
|
106
|
+
const [lastResult, setLastResult] = useState<PurchaseResult | undefined>();
|
|
107
|
+
|
|
108
|
+
const purchase = useCallback(
|
|
109
|
+
async (
|
|
110
|
+
productKey: string,
|
|
111
|
+
options?: PurchaseOptions,
|
|
112
|
+
): Promise<PurchaseResult> => {
|
|
113
|
+
setIsPurchasing(true);
|
|
114
|
+
try {
|
|
115
|
+
const result = await engine.purchase(productKey, options);
|
|
116
|
+
setLastResult(result);
|
|
117
|
+
return result;
|
|
118
|
+
} finally {
|
|
119
|
+
setIsPurchasing(false);
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
[engine],
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const consume = useCallback(
|
|
126
|
+
(ref: TransactionRef): Promise<FinalizeResult> => engine.consume(ref),
|
|
127
|
+
[engine],
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
return { purchase, consume, isPurchasing, lastResult };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface UseProductsResult {
|
|
134
|
+
products: ProductInfo[];
|
|
135
|
+
/** Reported so a paywall explains an absent row instead of rendering a blank. */
|
|
136
|
+
missing: MissingProduct[];
|
|
137
|
+
isLoading: boolean;
|
|
138
|
+
error: PaymentError | undefined;
|
|
139
|
+
refresh: () => Promise<void>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Store-supplied product information for a paywall.
|
|
144
|
+
*
|
|
145
|
+
* Every price here is already formatted by the store for the user's locale. An app
|
|
146
|
+
* must render `formattedPrice` and never build its own string from
|
|
147
|
+
* `priceAmountMicros` — that field exists for sorting and comparison only
|
|
148
|
+
* (FR-028).
|
|
149
|
+
*/
|
|
150
|
+
export function useProducts(productKeys: readonly string[]): UseProductsResult {
|
|
151
|
+
const engine = usePaymentEngine();
|
|
152
|
+
const [products, setProducts] = useState<ProductInfo[]>([]);
|
|
153
|
+
const [missing, setMissing] = useState<MissingProduct[]>([]);
|
|
154
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
155
|
+
const [error, setError] = useState<PaymentError | undefined>();
|
|
156
|
+
|
|
157
|
+
// Joined so the effect depends on the contents rather than the array identity:
|
|
158
|
+
// a caller passing a literal would otherwise re-query on every render.
|
|
159
|
+
const keyToken = productKeys.join('|');
|
|
160
|
+
|
|
161
|
+
const load = useCallback(
|
|
162
|
+
async (fresh: boolean): Promise<void> => {
|
|
163
|
+
const keys = keyToken === '' ? [] : keyToken.split('|');
|
|
164
|
+
if (keys.length === 0) {
|
|
165
|
+
setProducts([]);
|
|
166
|
+
setMissing([]);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
setIsLoading(true);
|
|
170
|
+
try {
|
|
171
|
+
const result = fresh
|
|
172
|
+
? await engine.refreshProducts(keys)
|
|
173
|
+
: await engine.getProducts(keys);
|
|
174
|
+
if (result.ok) {
|
|
175
|
+
setProducts(result.products);
|
|
176
|
+
setMissing(result.missing);
|
|
177
|
+
setError(undefined);
|
|
178
|
+
} else {
|
|
179
|
+
setError(result.error);
|
|
180
|
+
}
|
|
181
|
+
} finally {
|
|
182
|
+
setIsLoading(false);
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
[engine, keyToken],
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
load(false).catch(() => undefined);
|
|
190
|
+
}, [load]);
|
|
191
|
+
|
|
192
|
+
const refresh = useCallback(() => load(true), [load]);
|
|
193
|
+
|
|
194
|
+
return { products, missing, isLoading, error, refresh };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface UseSyncResult {
|
|
198
|
+
sync: () => Promise<SyncOutcome>;
|
|
199
|
+
restore: () => Promise<RestoreOutcome>;
|
|
200
|
+
isSyncing: boolean;
|
|
201
|
+
lastSyncedAt: number | undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Sync and restore, for a "Restore purchases" control.
|
|
206
|
+
*
|
|
207
|
+
* `restore`'s outcome distinguishes "restored" from "nothing found", so the app
|
|
208
|
+
* can avoid telling a user their purchases came back when none existed (FR-061).
|
|
209
|
+
*/
|
|
210
|
+
export function useSync(): UseSyncResult {
|
|
211
|
+
const engine = usePaymentEngine();
|
|
212
|
+
const [isSyncing, setIsSyncing] = useState(false);
|
|
213
|
+
const [lastSyncedAt, setLastSyncedAt] = useState<number | undefined>(() =>
|
|
214
|
+
engine.getLastSyncedAt(),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const sync = useCallback(async (): Promise<SyncOutcome> => {
|
|
218
|
+
setIsSyncing(true);
|
|
219
|
+
try {
|
|
220
|
+
return await engine.sync('manual');
|
|
221
|
+
} finally {
|
|
222
|
+
setIsSyncing(false);
|
|
223
|
+
setLastSyncedAt(engine.getLastSyncedAt());
|
|
224
|
+
}
|
|
225
|
+
}, [engine]);
|
|
226
|
+
|
|
227
|
+
const restore = useCallback(async (): Promise<RestoreOutcome> => {
|
|
228
|
+
setIsSyncing(true);
|
|
229
|
+
try {
|
|
230
|
+
return await engine.restore();
|
|
231
|
+
} finally {
|
|
232
|
+
setIsSyncing(false);
|
|
233
|
+
setLastSyncedAt(engine.getLastSyncedAt());
|
|
234
|
+
}
|
|
235
|
+
}, [engine]);
|
|
236
|
+
|
|
237
|
+
return { sync, restore, isSyncing, lastSyncedAt };
|
|
238
|
+
}
|