@chipmobilesdk/rn-payment 0.4.1 → 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.
@@ -15,6 +15,7 @@ import React, {
15
15
  type PropsWithChildren,
16
16
  } from 'react';
17
17
  import { createEngine, type EngineDeps, type PaymentEngine } from '../engine/engine';
18
+ import { syncOnForeground as watchForeground } from '../engine/foreground';
18
19
  import type { PaymentConfig } from '../config/types';
19
20
 
20
21
  const PaymentContext = createContext<PaymentEngine | null>(null);
@@ -32,12 +33,19 @@ export interface PaymentProviderProps {
32
33
  deps: EngineDeps;
33
34
  /** Skip automatic initialization when the app wants to control the moment. */
34
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;
35
42
  }
36
43
 
37
44
  export function PaymentProvider({
38
45
  config,
39
46
  deps,
40
47
  autoInitialize = true,
48
+ syncOnForeground = false,
41
49
  children,
42
50
  }: PropsWithChildren<PaymentProviderProps>): React.ReactElement {
43
51
  // Built once per config identity. Rebuilding on every render would reconnect
@@ -55,6 +63,11 @@ export function PaymentProvider({
55
63
  return () => engine.cleanup();
56
64
  }, [engine, autoInitialize]);
57
65
 
66
+ useEffect(
67
+ () => (syncOnForeground ? watchForeground(engine) : undefined),
68
+ [engine, syncOnForeground],
69
+ );
70
+
58
71
  return <PaymentContext.Provider value={engine}>{children}</PaymentContext.Provider>;
59
72
  }
60
73
 
@@ -8,6 +8,7 @@
8
8
  * returns a loading placeholder for an entitlement it already knows (PERF-001).
9
9
  */
10
10
 
11
+ import { useExternalStore } from '@chipmobilesdk/rn-core/react';
11
12
  import { useCallback, useEffect, useState } from 'react';
12
13
  import { usePaymentEngine } from './PaymentProvider';
13
14
  import type {
@@ -33,34 +34,19 @@ import type { RestoreOutcome, SyncOutcome } from '../engine/engine';
33
34
  */
34
35
  export function useEntitlement(key: EntitlementKey): EntitlementState {
35
36
  const engine = usePaymentEngine();
36
- const [state, setState] = useState<EntitlementState>(() => engine.getEntitlement(key));
37
-
38
- useEffect(() => {
39
- // Re-read on subscribe: the entitlement may have changed between the initial
40
- // render and this effect.
41
- setState(engine.getEntitlement(key));
42
- return engine.subscribeEntitlements(map => {
43
- const next = map[key];
44
- if (next) {
45
- setState(next);
46
- }
47
- });
48
- }, [engine, key]);
49
-
50
- return state;
37
+ // The engine keeps an unchanged entitlement's object across recomputes, so this
38
+ // selection only changes, and the component only re-renders, when it changes.
39
+ const select = useCallback(
40
+ (map: EntitlementMap) => map[key] ?? engine.getEntitlement(key),
41
+ [engine, key],
42
+ );
43
+ return useExternalStore(engine.subscribeEntitlements, engine.getAllEntitlements, select);
51
44
  }
52
45
 
53
46
  /** Every entitlement at once, for a screen that shows overall plan state. */
54
47
  export function useEntitlements(): EntitlementMap {
55
48
  const engine = usePaymentEngine();
56
- const [map, setMap] = useState<EntitlementMap>(() => engine.getAllEntitlements());
57
-
58
- useEffect(() => {
59
- setMap(engine.getAllEntitlements());
60
- return engine.subscribeEntitlements(setMap);
61
- }, [engine]);
62
-
63
- return map;
49
+ return useExternalStore(engine.subscribeEntitlements, engine.getAllEntitlements);
64
50
  }
65
51
 
66
52
  function narrowPending(
@@ -99,14 +85,7 @@ export function usePendingPurchases(productKeys?: readonly string[]): PendingPur
99
85
  /** Package status, so an app can tell "cannot sell here" from "not paid" (FR-022). */
100
86
  export function usePaymentStatus(): PaymentStatus {
101
87
  const engine = usePaymentEngine();
102
- const [status, setStatus] = useState<PaymentStatus>(() => engine.getStatus());
103
-
104
- useEffect(() => {
105
- setStatus(engine.getStatus());
106
- return engine.subscribeStatus(setStatus);
107
- }, [engine]);
108
-
109
- return status;
88
+ return useExternalStore(engine.subscribeStatus, engine.getStatus);
110
89
  }
111
90
 
112
91
  export interface UsePurchaseResult {
@@ -0,0 +1,169 @@
1
+ /**
2
+ * What any `ProviderPort` must do (IOS-12, PAY-13).
3
+ *
4
+ * The same checks run against the fake store and against the Play adapter over a
5
+ * simulated binding (or the real one, from a demo screen on a device), so a test
6
+ * that drives the fake proves something about the device path. Each check builds
7
+ * its own port over the same small store, through a harness.
8
+ */
9
+ import {
10
+ ensure,
11
+ runChecks,
12
+ type Check,
13
+ type ConformanceReport,
14
+ } from '@chipmobilesdk/rn-core/testing';
15
+
16
+ import type { ProviderPort } from '../adapter/port';
17
+ import type { ProductType } from '../config/types';
18
+
19
+ export interface StoreProductSpec {
20
+ readonly storeProductId: string;
21
+ readonly productType: ProductType;
22
+ /** As the store formats it; the port must pass it through untouched. */
23
+ readonly formattedPrice: string;
24
+ }
25
+
26
+ export interface OwnedPurchaseSpec {
27
+ readonly storeProductId: string;
28
+ readonly token: string;
29
+ readonly state: 'purchased' | 'pending';
30
+ }
31
+
32
+ export interface StoreScenario {
33
+ readonly products: readonly StoreProductSpec[];
34
+ readonly owned: readonly OwnedPurchaseSpec[];
35
+ }
36
+
37
+ export interface ProviderSubject {
38
+ readonly port: ProviderPort;
39
+ /** How the next purchase flow the user starts ends. */
40
+ nextPurchaseEnds(outcome: 'bought' | 'cancelled'): void;
41
+ /** The billing service goes away underneath the app. */
42
+ dropService(): void;
43
+ }
44
+
45
+ export interface ProviderHarness {
46
+ create(scenario: StoreScenario): ProviderSubject;
47
+ }
48
+
49
+ /** The store every check runs against. */
50
+ export const CONFORMANCE_STORE: StoreScenario = {
51
+ products: [
52
+ {
53
+ storeProductId: 'conformance.remove_ads',
54
+ productType: 'one_time_permanent',
55
+ formattedPrice: '€1.99',
56
+ },
57
+ {
58
+ storeProductId: 'conformance.coins',
59
+ productType: 'one_time_consumable',
60
+ formattedPrice: '€0.99',
61
+ },
62
+ ],
63
+ owned: [
64
+ { storeProductId: 'conformance.remove_ads', token: 'token-owned', state: 'purchased' },
65
+ { storeProductId: 'conformance.coins', token: 'token-pending', state: 'pending' },
66
+ ],
67
+ };
68
+
69
+ export const providerPortChecks: readonly Check<ProviderHarness>[] = [
70
+ {
71
+ name: 'connects, and disconnects safely before a connect and twice',
72
+ async run(harness) {
73
+ const { port } = harness.create(CONFORMANCE_STORE);
74
+ await port.disconnect();
75
+ const outcome = await port.connect();
76
+ ensure(outcome.ok, `connect failed: ${JSON.stringify(outcome)}`);
77
+ await port.disconnect();
78
+ await port.disconnect();
79
+ },
80
+ },
81
+ {
82
+ name: 'reports a dropped service to observers, and nothing after unsubscribing',
83
+ async run(harness) {
84
+ const subject = harness.create(CONFORMANCE_STORE);
85
+ const seen: boolean[] = [];
86
+ const stop = subject.port.observeConnection(connected => seen.push(connected));
87
+ await subject.port.connect();
88
+ ensure(seen.at(-1) === true, `after connect observers saw ${JSON.stringify(seen)}`);
89
+ subject.dropService();
90
+ ensure(seen.at(-1) === false, `after the drop observers saw ${JSON.stringify(seen)}`);
91
+ stop();
92
+ const before = seen.length;
93
+ await subject.port.connect();
94
+ ensure(seen.length === before, 'notified after unsubscribing');
95
+ },
96
+ },
97
+ {
98
+ name: 'returns the requested products with the store-formatted price',
99
+ async run(harness) {
100
+ const { port } = harness.create(CONFORMANCE_STORE);
101
+ await port.connect();
102
+ const products = await port.queryProducts(['conformance.remove_ads'], 'one_time_permanent');
103
+ ensure(products.length === 1, `expected one product, got ${products.length}`);
104
+ const [product] = products;
105
+ ensure(product?.storeProductId === 'conformance.remove_ads', 'wrong product returned');
106
+ ensure(product.productType === 'one_time_permanent', `product type ${product.productType}`);
107
+ ensure(product.formattedPrice === '€1.99', `price ${product.formattedPrice}`);
108
+ },
109
+ },
110
+ {
111
+ name: 'flags a pending purchase rather than filtering it out',
112
+ async run(harness) {
113
+ const { port } = harness.create(CONFORMANCE_STORE);
114
+ await port.connect();
115
+ const owned = await port.queryOwned();
116
+ const pending = owned.find(purchase => purchase.token === 'token-pending');
117
+ const bought = owned.find(purchase => purchase.token === 'token-owned');
118
+ ensure(pending?.state === 'pending', `pending purchase reported as ${pending?.state}`);
119
+ ensure(bought?.state === 'purchased', `bought purchase reported as ${bought?.state}`);
120
+ },
121
+ },
122
+ {
123
+ name: 'reports a bought purchase for the product that was requested',
124
+ async run(harness) {
125
+ const subject = harness.create(CONFORMANCE_STORE);
126
+ await subject.port.connect();
127
+ subject.nextPurchaseEnds('bought');
128
+ const outcome = await subject.port.launchPurchase({
129
+ storeProductId: 'conformance.coins',
130
+ productType: 'one_time_consumable',
131
+ });
132
+ ensure(outcome.ok, `purchase failed: ${JSON.stringify(outcome)}`);
133
+ ensure(
134
+ outcome.purchase.storeProductId === 'conformance.coins',
135
+ 'purchase for another product',
136
+ );
137
+ ensure(outcome.purchase.token !== '', 'purchase has no token to finalize');
138
+ },
139
+ },
140
+ {
141
+ name: 'reports a cancelled flow as user_cancelled',
142
+ async run(harness) {
143
+ const subject = harness.create(CONFORMANCE_STORE);
144
+ await subject.port.connect();
145
+ subject.nextPurchaseEnds('cancelled');
146
+ const outcome = await subject.port.launchPurchase({
147
+ storeProductId: 'conformance.remove_ads',
148
+ productType: 'one_time_permanent',
149
+ });
150
+ ensure(!outcome.ok, 'a cancelled flow reported a purchase');
151
+ ensure(outcome.reason === 'user_cancelled', `reason ${outcome.reason}`);
152
+ },
153
+ },
154
+ {
155
+ name: 'acknowledges and consumes safely twice',
156
+ async run(harness) {
157
+ const { port } = harness.create(CONFORMANCE_STORE);
158
+ await port.connect();
159
+ await port.acknowledge('token-owned');
160
+ await port.acknowledge('token-owned');
161
+ await port.consume('token-pending');
162
+ await port.consume('token-pending');
163
+ },
164
+ },
165
+ ];
166
+
167
+ export function runProviderPortConformance(harness: ProviderHarness): Promise<ConformanceReport> {
168
+ return runChecks(() => harness, providerPortChecks);
169
+ }
@@ -11,6 +11,8 @@
11
11
  * testable at all (NFR-007, SC-008).
12
12
  */
13
13
 
14
+ import { createListenerSet } from '@chipmobilesdk/rn-core';
15
+
14
16
  import type {
15
17
  ProviderCapabilities,
16
18
  ProviderConnectOutcome,
@@ -154,8 +156,10 @@ export function createFakeStore(options: FakeStoreOptions = {}): FakeStore {
154
156
  const acknowledgedTokens: ProviderToken[] = [];
155
157
  const consumedTokens: ProviderToken[] = [];
156
158
  const managementOpenedFor: (StoreProductId | undefined)[] = [];
157
- const connectionListeners = new Set<(connected: boolean, reason?: string) => void>();
158
- const purchaseListeners = new Set<(purchase: ProviderPurchase) => void>();
159
+ // The same listener sets as the real adapter, so a throwing observer behaves
160
+ // identically here and on a device (PAY-17).
161
+ const connectionListeners = createListenerSet<[boolean, string | undefined]>();
162
+ const purchaseListeners = createListenerSet<[ProviderPurchase]>();
159
163
 
160
164
  let connectCalls = 0;
161
165
  let launchCalls = 0;
@@ -164,9 +168,7 @@ export function createFakeStore(options: FakeStoreOptions = {}): FakeStore {
164
168
 
165
169
  const notifyConnection = (next: boolean, reason?: string): void => {
166
170
  connected = next;
167
- for (const listener of [...connectionListeners]) {
168
- listener(next, reason);
169
- }
171
+ connectionListeners.notify(next, reason);
170
172
  };
171
173
 
172
174
  return {
@@ -187,15 +189,7 @@ export function createFakeStore(options: FakeStoreOptions = {}): FakeStore {
187
189
  },
188
190
 
189
191
  observeConnection(listener): Unsubscribe {
190
- connectionListeners.add(listener);
191
- let removed = false;
192
- return () => {
193
- if (removed) {
194
- return;
195
- }
196
- removed = true;
197
- connectionListeners.delete(listener);
198
- };
192
+ return connectionListeners.add(listener);
199
193
  },
200
194
 
201
195
  async queryProducts(
@@ -235,23 +229,13 @@ export function createFakeStore(options: FakeStoreOptions = {}): FakeStore {
235
229
  if (androidBinding) {
236
230
  // The binding's update listener fires for every observer, the session-long
237
231
  // one included, before the launch settles.
238
- for (const listener of [...purchaseListeners]) {
239
- listener(purchase);
240
- }
232
+ purchaseListeners.notify(purchase);
241
233
  }
242
234
  return { ok: true, purchase };
243
235
  },
244
236
 
245
237
  observePurchases(listener): Unsubscribe {
246
- purchaseListeners.add(listener);
247
- let removed = false;
248
- return () => {
249
- if (removed) {
250
- return;
251
- }
252
- removed = true;
253
- purchaseListeners.delete(listener);
254
- };
238
+ return purchaseListeners.add(listener);
255
239
  },
256
240
 
257
241
  async acknowledge(token: ProviderToken): Promise<void> {
@@ -306,9 +290,7 @@ export function createFakeStore(options: FakeStoreOptions = {}): FakeStore {
306
290
  },
307
291
  pushPurchaseUpdate(purchase) {
308
292
  owned = [...owned.filter(p => p.transactionId !== purchase.transactionId), purchase];
309
- for (const listener of [...purchaseListeners]) {
310
- listener(purchase);
311
- }
293
+ purchaseListeners.notify(purchase);
312
294
  },
313
295
  revoke(transactionId) {
314
296
  owned = owned.filter(p => p.transactionId !== transactionId);
@@ -8,3 +8,12 @@
8
8
  export { createFakeStore, makePurchase } from './fakeStore';
9
9
  export type { FakeStore, FakeStoreOptions } from './fakeStore';
10
10
  export { createMemorySnapshotStore } from '../persistence/mmkv-store';
11
+
12
+ export { CONFORMANCE_STORE, providerPortChecks, runProviderPortConformance } from './conformance';
13
+ export type {
14
+ OwnedPurchaseSpec,
15
+ ProviderHarness,
16
+ ProviderSubject,
17
+ StoreProductSpec,
18
+ StoreScenario,
19
+ } from './conformance';