@chipmobilesdk/rn-payment 0.4.1 → 0.6.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.
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The billing connection: connecting, watching it, and reconnecting with bounded
3
+ * backoff (FR-018 – FR-024, PAY-10).
4
+ *
5
+ * A reconnect is always a full teardown-then-connect cycle. The upstream
6
+ * binding's own `reconnect()` has reported success without rebuilding anything
7
+ * (openiap#408), so no shortcut is trusted here.
8
+ */
9
+ import type { ProviderPurchase } from '../adapter/port';
10
+ import type { Unsubscribe } from '../config/types';
11
+ import { backoffDelayMs } from '../core/retry';
12
+ import { withTimeout } from '../core/mutex';
13
+ import { PaymentError } from '../diagnostics/errors';
14
+ import type { EngineState } from './state';
15
+
16
+ export type InitOutcome = { ok: true } | { ok: false; error: PaymentError };
17
+
18
+ export interface Connection {
19
+ /** Connects and attaches the watchers; status follows the outcome. */
20
+ connect(): Promise<InitOutcome>;
21
+ /**
22
+ * A fresh reconnect budget and one immediate attempt, when an earlier budget
23
+ * ran out: a foreground return is when the network the app lost is usually
24
+ * back.
25
+ */
26
+ recover(): Promise<void>;
27
+ dispose(): void;
28
+ }
29
+
30
+ export function createConnection(
31
+ state: EngineState,
32
+ onPurchase: (purchase: ProviderPurchase) => void,
33
+ ): Connection {
34
+ const { provider, resolved } = state;
35
+ let connectionSub: Unsubscribe | undefined;
36
+ let purchaseSub: Unsubscribe | undefined;
37
+ let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
38
+ let reconnectAttempts = 0;
39
+ /**
40
+ * Set once a connection succeeded. Recovery after an exhausted budget is only
41
+ * meaningful from there; a first connection that failed is the app's to retry
42
+ * through `initialize`.
43
+ */
44
+ let everConnected = false;
45
+
46
+ function scheduleReconnect(): void {
47
+ if (state.disposed) {
48
+ return;
49
+ }
50
+ if (reconnectAttempts >= resolved.retry.reconnect.maxAttempts) {
51
+ // Out of budget for now, not for good: a retryable failure rather than a
52
+ // `connecting` that promised a reconnect nobody was going to make.
53
+ state.setStatus({ kind: 'failed', retryable: true, reconnectAttempts });
54
+ return;
55
+ }
56
+ const attempt = reconnectAttempts + 1;
57
+ reconnectTimer = setTimeout(
58
+ () => {
59
+ reconnectTimer = undefined;
60
+ if (state.disposed) {
61
+ return;
62
+ }
63
+ reconnectAttempts = attempt;
64
+ state.setStatus({ kind: 'connecting', reconnectAttempts });
65
+ reconnectOnce().catch(() => undefined);
66
+ },
67
+ backoffDelayMs(attempt, resolved.retry.reconnect),
68
+ );
69
+ }
70
+
71
+ async function reconnectOnce(): Promise<void> {
72
+ try {
73
+ await provider.disconnect();
74
+ } catch {
75
+ // Already down; nothing to tear down.
76
+ }
77
+ const outcome = await provider.connect();
78
+ if (state.disposed) {
79
+ return;
80
+ }
81
+ if (outcome.ok) {
82
+ reconnectAttempts = 0;
83
+ state.setStatus({ kind: 'ready', reconnectAttempts: 0 });
84
+ } else {
85
+ scheduleReconnect();
86
+ }
87
+ }
88
+
89
+ /** Replaced, never stacked: a second subscription would hear everything twice. */
90
+ function watch(): void {
91
+ connectionSub?.();
92
+ connectionSub = provider.observeConnection((connected, reason) => {
93
+ if (state.disposed) {
94
+ return;
95
+ }
96
+ state.emit({ type: 'connection_changed', connected, reason, attempt: reconnectAttempts });
97
+ if (!connected && state.status.kind === 'ready') {
98
+ state.setStatus({ kind: 'connecting' });
99
+ scheduleReconnect();
100
+ }
101
+ });
102
+ // Session-long: a pending purchase settling between sessions arrives here
103
+ // (FR-039), not only while a purchase is open.
104
+ purchaseSub?.();
105
+ purchaseSub = provider.observePurchases(purchase => {
106
+ if (!state.disposed) {
107
+ onPurchase(purchase);
108
+ }
109
+ });
110
+ }
111
+
112
+ return {
113
+ async connect() {
114
+ const outcome = await withTimeout(
115
+ provider.connect(),
116
+ resolved.timeouts.connectMs,
117
+ () =>
118
+ new PaymentError({
119
+ code: 'PAYMENT_TIMEOUT',
120
+ message: 'Connecting to the billing service timed out',
121
+ operation: 'initialize',
122
+ retryable: true,
123
+ }),
124
+ ).catch((error: PaymentError) => ({
125
+ ok: false as const,
126
+ reason: 'store_error' as const,
127
+ retryable: error.retryable,
128
+ }));
129
+
130
+ if (!outcome.ok) {
131
+ if (outcome.reason === 'store_error') {
132
+ state.setStatus({ kind: 'failed', retryable: outcome.retryable });
133
+ return {
134
+ ok: false,
135
+ error: new PaymentError({
136
+ code: 'PAYMENT_STORE_ERROR',
137
+ message: 'Could not connect to the billing service',
138
+ operation: 'initialize',
139
+ retryable: outcome.retryable,
140
+ }),
141
+ };
142
+ }
143
+ // No billing service, an outdated one, or an unsupported platform: each
144
+ // distinguishable, and none of them may break the host app (FR-021, FR-023).
145
+ state.setStatus({
146
+ kind: 'unavailable',
147
+ unavailableReason: outcome.reason,
148
+ retryable: outcome.retryable,
149
+ });
150
+ return {
151
+ ok: false,
152
+ error: new PaymentError({
153
+ code:
154
+ outcome.reason === 'platform_unsupported'
155
+ ? 'PAYMENT_UNSUPPORTED_PLATFORM'
156
+ : 'PAYMENT_UNAVAILABLE',
157
+ message: `Billing unavailable: ${outcome.reason}`,
158
+ operation: 'initialize',
159
+ retryable: outcome.retryable,
160
+ }),
161
+ };
162
+ }
163
+
164
+ watch();
165
+ everConnected = true;
166
+ state.setStatus({ kind: 'ready', reconnectAttempts: 0 });
167
+ return { ok: true };
168
+ },
169
+
170
+ async recover() {
171
+ if (
172
+ everConnected &&
173
+ !state.disposed &&
174
+ state.status.kind === 'failed' &&
175
+ reconnectTimer === undefined
176
+ ) {
177
+ reconnectAttempts = 0;
178
+ state.setStatus({ kind: 'connecting', reconnectAttempts: 0 });
179
+ await reconnectOnce().catch(() => undefined);
180
+ }
181
+ },
182
+
183
+ dispose() {
184
+ connectionSub?.();
185
+ connectionSub = undefined;
186
+ purchaseSub?.();
187
+ purchaseSub = undefined;
188
+ if (reconnectTimer !== undefined) {
189
+ clearTimeout(reconnectTimer);
190
+ reconnectTimer = undefined;
191
+ }
192
+ },
193
+ };
194
+ }