@chipmobilesdk/rn-payment 0.1.0 → 0.2.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 +90 -1
- package/README.md +885 -761
- package/package.json +54 -54
- package/skills/sdk-rn-payment/SKILL.md +33 -6
- package/src/adapter/createStoreProvider.ts +117 -117
- package/src/adapter/iap-adapter.ts +547 -516
- package/src/adapter/port.ts +12 -0
- package/src/config/types.ts +40 -0
- package/src/diagnostics/events.ts +9 -0
- package/src/engine/engine.ts +951 -854
- package/src/engine/finalize.ts +298 -298
- package/src/engine/pending.ts +118 -0
- package/src/engine/products.ts +259 -259
- package/src/engine/purchase.ts +526 -495
- package/src/engine/subscriptions.ts +157 -157
- package/src/entitlements/aggregate.ts +21 -4
- package/src/index.ts +4 -0
- package/src/react/hooks.ts +274 -238
- package/src/testing/fakeStore.ts +33 -0
package/src/engine/finalize.ts
CHANGED
|
@@ -1,298 +1,298 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The finalization queue — the package's hardest invariant (FR-041, FR-042).
|
|
3
|
-
*
|
|
4
|
-
* Google auto-refunds and revokes a purchase that is not acknowledged within
|
|
5
|
-
* **three days**. So finalization cannot be a step the app might forget, and it
|
|
6
|
-
* cannot live only in memory: the failure case FR-042 names is the app being
|
|
7
|
-
* force-killed in the instant between payment and acknowledgement.
|
|
8
|
-
*
|
|
9
|
-
* The ordering that makes this work is: **write the intent, then call the store.**
|
|
10
|
-
* A crash before the write means the purchase is still owned and the next sync
|
|
11
|
-
* re-queues it from `queryOwned`. A crash after the write means the queue has a
|
|
12
|
-
* record and the next startup drains it. There is no window where a paid
|
|
13
|
-
* transaction is forgotten.
|
|
14
|
-
*
|
|
15
|
-
* Consumption stays app-triggered (FR-043), because only the app knows whether
|
|
16
|
-
* the item was actually banked in its own data.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import type {
|
|
20
|
-
FinalizeResult,
|
|
21
|
-
ProductKey,
|
|
22
|
-
TransactionRef,
|
|
23
|
-
UnfinalizedSummary,
|
|
24
|
-
} from '../config/types';
|
|
25
|
-
import type { ProviderPort } from '../adapter/port';
|
|
26
|
-
import type { ResolvedRetry } from '../core/timers';
|
|
27
|
-
import { backoffDelayMs } from '../core/retry';
|
|
28
|
-
import {
|
|
29
|
-
pendingFinalizations,
|
|
30
|
-
type PersistedPurchase,
|
|
31
|
-
type PersistedSnapshot,
|
|
32
|
-
} from '../persistence/snapshot';
|
|
33
|
-
import type { PaymentEventInput } from '../diagnostics/events';
|
|
34
|
-
|
|
35
|
-
export interface FinalizerDeps {
|
|
36
|
-
provider: ProviderPort;
|
|
37
|
-
retry: ResolvedRetry;
|
|
38
|
-
snapshot: () => PersistedSnapshot;
|
|
39
|
-
commit: (next: PersistedSnapshot) => void;
|
|
40
|
-
emit: (event: PaymentEventInput) => void;
|
|
41
|
-
now: () => number;
|
|
42
|
-
disposed: () => boolean;
|
|
43
|
-
/** Injected so a test can run the retry schedule without waiting it out. */
|
|
44
|
-
setTimeoutFn?: (fn: () => void, ms: number) => FinalizeTimerHandle;
|
|
45
|
-
clearTimeoutFn?: (handle: FinalizeTimerHandle) => void;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** A Node `Timeout` and a browser numeric id are both valid here. */
|
|
49
|
-
type FinalizeTimerHandle = ReturnType<typeof setTimeout> | number;
|
|
50
|
-
|
|
51
|
-
export interface Finalizer {
|
|
52
|
-
/** Records the intent to finalize, then attempts it. Safe to call repeatedly. */
|
|
53
|
-
enqueue(transactionId: string): Promise<FinalizeResult>;
|
|
54
|
-
/** Drains every outstanding finalization. Runs at startup and on foreground. */
|
|
55
|
-
drain(): Promise<void>;
|
|
56
|
-
/** App-triggered consumption of a consumable (FR-043). */
|
|
57
|
-
consume(ref: TransactionRef): Promise<FinalizeResult>;
|
|
58
|
-
/** Paid-but-unsettled transactions, so the condition is observable (FR-045). */
|
|
59
|
-
unfinalized(): UnfinalizedSummary[];
|
|
60
|
-
/** Cancels pending retries. Idempotent, called from cleanup. */
|
|
61
|
-
dispose(): void;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function createFinalizer(deps: FinalizerDeps): Finalizer {
|
|
65
|
-
const setTimeoutFn = deps.setTimeoutFn ?? setTimeout;
|
|
66
|
-
const clearTimeoutFn = deps.clearTimeoutFn ?? clearTimeout;
|
|
67
|
-
|
|
68
|
-
/** One timer per transaction, so two transactions retry independently. */
|
|
69
|
-
const timers = new Map<string, FinalizeTimerHandle>();
|
|
70
|
-
/** Guards against two drains racing the same transaction. */
|
|
71
|
-
const inFlight = new Set<string>();
|
|
72
|
-
|
|
73
|
-
const find = (transactionId: string): PersistedPurchase | undefined =>
|
|
74
|
-
deps.snapshot().purchases.find(p => p.transactionId === transactionId);
|
|
75
|
-
|
|
76
|
-
const patch = (
|
|
77
|
-
transactionId: string,
|
|
78
|
-
changes: Partial<PersistedPurchase>,
|
|
79
|
-
): void => {
|
|
80
|
-
const snapshot = deps.snapshot();
|
|
81
|
-
deps.commit({
|
|
82
|
-
...snapshot,
|
|
83
|
-
purchases: snapshot.purchases.map(p =>
|
|
84
|
-
p.transactionId === transactionId ? { ...p, ...changes } : p,
|
|
85
|
-
),
|
|
86
|
-
});
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const clearTimer = (transactionId: string): void => {
|
|
90
|
-
const timer = timers.get(transactionId);
|
|
91
|
-
if (timer !== undefined) {
|
|
92
|
-
clearTimeoutFn(timer);
|
|
93
|
-
timers.delete(transactionId);
|
|
94
|
-
}
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Attempts one finalize call.
|
|
99
|
-
*
|
|
100
|
-
* A consumable is **not** acknowledged here: it waits for the app's explicit
|
|
101
|
-
* `consume`. Acknowledging it now would tell the store the entitlement was
|
|
102
|
-
* delivered before the app has recorded the item, and a crash in between would
|
|
103
|
-
* lose a paid item with no way to recover it.
|
|
104
|
-
*/
|
|
105
|
-
const attempt = async (transactionId: string): Promise<FinalizeResult> => {
|
|
106
|
-
if (deps.disposed()) {
|
|
107
|
-
return { kind: 'failed', code: 'PAYMENT_NOT_CONFIGURED' };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const record = find(transactionId);
|
|
111
|
-
if (!record) {
|
|
112
|
-
return { kind: 'unknown_transaction' };
|
|
113
|
-
}
|
|
114
|
-
if (record.finalizeState === 'done') {
|
|
115
|
-
// Idempotent by design: calling twice is a success, not an error (FR-044).
|
|
116
|
-
return { kind: 'already_finalized' };
|
|
117
|
-
}
|
|
118
|
-
if (record.productType === 'one_time_consumable') {
|
|
119
|
-
// The app owns this moment. Nothing to do until it calls consume().
|
|
120
|
-
return { kind: 'already_finalized' };
|
|
121
|
-
}
|
|
122
|
-
if (inFlight.has(transactionId)) {
|
|
123
|
-
return { kind: 'retry_scheduled', attempts: record.attempts };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
inFlight.add(transactionId);
|
|
127
|
-
patch(transactionId, { finalizeState: 'in_flight' });
|
|
128
|
-
|
|
129
|
-
let failed = false;
|
|
130
|
-
try {
|
|
131
|
-
await deps.provider.acknowledge(record.token);
|
|
132
|
-
} catch {
|
|
133
|
-
failed = true;
|
|
134
|
-
} finally {
|
|
135
|
-
// Released before scheduling the retry, deliberately. The guard exists to
|
|
136
|
-
// stop two concurrent store calls for one transaction, nothing more — if
|
|
137
|
-
// it were still held here, the next scheduled attempt would be refused by
|
|
138
|
-
// our own lock and the retry schedule would silently stall after one try.
|
|
139
|
-
inFlight.delete(transactionId);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const attempts = record.attempts + 1;
|
|
143
|
-
|
|
144
|
-
if (!failed) {
|
|
145
|
-
clearTimer(transactionId);
|
|
146
|
-
patch(transactionId, {
|
|
147
|
-
finalizeState: 'done',
|
|
148
|
-
state: 'purchased',
|
|
149
|
-
attempts,
|
|
150
|
-
});
|
|
151
|
-
deps.emit({
|
|
152
|
-
type: 'transaction_finalized',
|
|
153
|
-
productKey: record.productKey,
|
|
154
|
-
attempts,
|
|
155
|
-
});
|
|
156
|
-
return { kind: 'finalized' };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
patch(transactionId, { finalizeState: 'failed', attempts });
|
|
160
|
-
return scheduleRetry(transactionId, record.productKey, attempts);
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Schedules the next attempt inside the per-session budget.
|
|
165
|
-
*
|
|
166
|
-
* Exhausting the budget is **not** giving up. The record stays `failed`, which
|
|
167
|
-
* `pendingFinalizations` still returns, so the next startup or foreground drain
|
|
168
|
-
* picks it up again. That is what keeps the package inside Play's three-day
|
|
169
|
-
* window without hammering a store that is currently refusing.
|
|
170
|
-
*/
|
|
171
|
-
const scheduleRetry = (
|
|
172
|
-
transactionId: string,
|
|
173
|
-
productKey: ProductKey,
|
|
174
|
-
attempts: number,
|
|
175
|
-
): FinalizeResult => {
|
|
176
|
-
if (deps.disposed()) {
|
|
177
|
-
return { kind: 'failed', code: 'PAYMENT_FINALIZE_FAILED' };
|
|
178
|
-
}
|
|
179
|
-
if (attempts >= deps.retry.finalize.maxAttempts) {
|
|
180
|
-
// Left queued deliberately — resumed next session, never abandoned.
|
|
181
|
-
return { kind: 'failed', code: 'PAYMENT_FINALIZE_FAILED' };
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const delayMs = backoffDelayMs(attempts + 1, deps.retry.finalize);
|
|
185
|
-
clearTimer(transactionId);
|
|
186
|
-
timers.set(
|
|
187
|
-
transactionId,
|
|
188
|
-
setTimeoutFn(() => {
|
|
189
|
-
timers.delete(transactionId);
|
|
190
|
-
attempt(transactionId).catch(() => undefined);
|
|
191
|
-
}, delayMs),
|
|
192
|
-
);
|
|
193
|
-
|
|
194
|
-
deps.emit({
|
|
195
|
-
type: 'finalize_retry_scheduled',
|
|
196
|
-
productKey,
|
|
197
|
-
attempt: attempts + 1,
|
|
198
|
-
delayMs,
|
|
199
|
-
});
|
|
200
|
-
return { kind: 'retry_scheduled', attempts };
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
return {
|
|
204
|
-
async enqueue(transactionId: string): Promise<FinalizeResult> {
|
|
205
|
-
const record = find(transactionId);
|
|
206
|
-
if (!record) {
|
|
207
|
-
return { kind: 'unknown_transaction' };
|
|
208
|
-
}
|
|
209
|
-
if (record.finalizeState === 'done') {
|
|
210
|
-
return { kind: 'already_finalized' };
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// The write happens BEFORE the store call. This single ordering is what
|
|
214
|
-
// makes a kill between payment and acknowledgement recoverable.
|
|
215
|
-
if (record.finalizeState !== 'in_flight') {
|
|
216
|
-
patch(transactionId, { finalizeState: 'queued' });
|
|
217
|
-
}
|
|
218
|
-
return attempt(transactionId);
|
|
219
|
-
},
|
|
220
|
-
|
|
221
|
-
async drain(): Promise<void> {
|
|
222
|
-
// Runs regardless of whether a purchase happened this session: the record
|
|
223
|
-
// it is looking for was probably written by a session that died.
|
|
224
|
-
for (const record of pendingFinalizations(deps.snapshot())) {
|
|
225
|
-
if (deps.disposed()) {
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
await attempt(record.transactionId).catch(() => undefined);
|
|
229
|
-
}
|
|
230
|
-
},
|
|
231
|
-
|
|
232
|
-
async consume(ref: TransactionRef): Promise<FinalizeResult> {
|
|
233
|
-
const record = find(ref.transactionId);
|
|
234
|
-
if (!record) {
|
|
235
|
-
return { kind: 'unknown_transaction' };
|
|
236
|
-
}
|
|
237
|
-
if (record.state === 'consumed') {
|
|
238
|
-
// Consuming twice must grant the item once (FR-044).
|
|
239
|
-
return { kind: 'already_finalized' };
|
|
240
|
-
}
|
|
241
|
-
if (inFlight.has(ref.transactionId)) {
|
|
242
|
-
// Concurrent consume calls collapse to one, for the same reason.
|
|
243
|
-
return { kind: 'already_finalized' };
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
inFlight.add(ref.transactionId);
|
|
247
|
-
patch(ref.transactionId, { finalizeState: 'in_flight' });
|
|
248
|
-
|
|
249
|
-
let failed = false;
|
|
250
|
-
try {
|
|
251
|
-
await deps.provider.consume(record.token);
|
|
252
|
-
} catch {
|
|
253
|
-
failed = true;
|
|
254
|
-
} finally {
|
|
255
|
-
// Released before scheduling, for the same reason as in `attempt`.
|
|
256
|
-
inFlight.delete(ref.transactionId);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const attempts = record.attempts + 1;
|
|
260
|
-
|
|
261
|
-
if (failed) {
|
|
262
|
-
patch(ref.transactionId, { finalizeState: 'failed', attempts });
|
|
263
|
-
return scheduleRetry(ref.transactionId, record.productKey, attempts);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
clearTimer(ref.transactionId);
|
|
267
|
-
// `consumed` is what stops it being an entitlement source, so a
|
|
268
|
-
// consumable never becomes a permanent unlock.
|
|
269
|
-
patch(ref.transactionId, {
|
|
270
|
-
state: 'consumed',
|
|
271
|
-
finalizeState: 'done',
|
|
272
|
-
attempts,
|
|
273
|
-
});
|
|
274
|
-
deps.emit({
|
|
275
|
-
type: 'transaction_consumed',
|
|
276
|
-
productKey: record.productKey,
|
|
277
|
-
});
|
|
278
|
-
return { kind: 'finalized' };
|
|
279
|
-
},
|
|
280
|
-
|
|
281
|
-
unfinalized(): UnfinalizedSummary[] {
|
|
282
|
-
return pendingFinalizations(deps.snapshot()).map(p => ({
|
|
283
|
-
productKey: p.productKey,
|
|
284
|
-
transactionId: p.transactionId,
|
|
285
|
-
attempts: p.attempts,
|
|
286
|
-
purchasedAt: p.purchasedAt,
|
|
287
|
-
}));
|
|
288
|
-
},
|
|
289
|
-
|
|
290
|
-
dispose(): void {
|
|
291
|
-
for (const timer of timers.values()) {
|
|
292
|
-
clearTimeoutFn(timer);
|
|
293
|
-
}
|
|
294
|
-
timers.clear();
|
|
295
|
-
inFlight.clear();
|
|
296
|
-
},
|
|
297
|
-
};
|
|
298
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The finalization queue — the package's hardest invariant (FR-041, FR-042).
|
|
3
|
+
*
|
|
4
|
+
* Google auto-refunds and revokes a purchase that is not acknowledged within
|
|
5
|
+
* **three days**. So finalization cannot be a step the app might forget, and it
|
|
6
|
+
* cannot live only in memory: the failure case FR-042 names is the app being
|
|
7
|
+
* force-killed in the instant between payment and acknowledgement.
|
|
8
|
+
*
|
|
9
|
+
* The ordering that makes this work is: **write the intent, then call the store.**
|
|
10
|
+
* A crash before the write means the purchase is still owned and the next sync
|
|
11
|
+
* re-queues it from `queryOwned`. A crash after the write means the queue has a
|
|
12
|
+
* record and the next startup drains it. There is no window where a paid
|
|
13
|
+
* transaction is forgotten.
|
|
14
|
+
*
|
|
15
|
+
* Consumption stays app-triggered (FR-043), because only the app knows whether
|
|
16
|
+
* the item was actually banked in its own data.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type {
|
|
20
|
+
FinalizeResult,
|
|
21
|
+
ProductKey,
|
|
22
|
+
TransactionRef,
|
|
23
|
+
UnfinalizedSummary,
|
|
24
|
+
} from '../config/types';
|
|
25
|
+
import type { ProviderPort } from '../adapter/port';
|
|
26
|
+
import type { ResolvedRetry } from '../core/timers';
|
|
27
|
+
import { backoffDelayMs } from '../core/retry';
|
|
28
|
+
import {
|
|
29
|
+
pendingFinalizations,
|
|
30
|
+
type PersistedPurchase,
|
|
31
|
+
type PersistedSnapshot,
|
|
32
|
+
} from '../persistence/snapshot';
|
|
33
|
+
import type { PaymentEventInput } from '../diagnostics/events';
|
|
34
|
+
|
|
35
|
+
export interface FinalizerDeps {
|
|
36
|
+
provider: ProviderPort;
|
|
37
|
+
retry: ResolvedRetry;
|
|
38
|
+
snapshot: () => PersistedSnapshot;
|
|
39
|
+
commit: (next: PersistedSnapshot) => void;
|
|
40
|
+
emit: (event: PaymentEventInput) => void;
|
|
41
|
+
now: () => number;
|
|
42
|
+
disposed: () => boolean;
|
|
43
|
+
/** Injected so a test can run the retry schedule without waiting it out. */
|
|
44
|
+
setTimeoutFn?: (fn: () => void, ms: number) => FinalizeTimerHandle;
|
|
45
|
+
clearTimeoutFn?: (handle: FinalizeTimerHandle) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A Node `Timeout` and a browser numeric id are both valid here. */
|
|
49
|
+
type FinalizeTimerHandle = ReturnType<typeof setTimeout> | number;
|
|
50
|
+
|
|
51
|
+
export interface Finalizer {
|
|
52
|
+
/** Records the intent to finalize, then attempts it. Safe to call repeatedly. */
|
|
53
|
+
enqueue(transactionId: string): Promise<FinalizeResult>;
|
|
54
|
+
/** Drains every outstanding finalization. Runs at startup and on foreground. */
|
|
55
|
+
drain(): Promise<void>;
|
|
56
|
+
/** App-triggered consumption of a consumable (FR-043). */
|
|
57
|
+
consume(ref: TransactionRef): Promise<FinalizeResult>;
|
|
58
|
+
/** Paid-but-unsettled transactions, so the condition is observable (FR-045). */
|
|
59
|
+
unfinalized(): UnfinalizedSummary[];
|
|
60
|
+
/** Cancels pending retries. Idempotent, called from cleanup. */
|
|
61
|
+
dispose(): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createFinalizer(deps: FinalizerDeps): Finalizer {
|
|
65
|
+
const setTimeoutFn = deps.setTimeoutFn ?? setTimeout;
|
|
66
|
+
const clearTimeoutFn = deps.clearTimeoutFn ?? clearTimeout;
|
|
67
|
+
|
|
68
|
+
/** One timer per transaction, so two transactions retry independently. */
|
|
69
|
+
const timers = new Map<string, FinalizeTimerHandle>();
|
|
70
|
+
/** Guards against two drains racing the same transaction. */
|
|
71
|
+
const inFlight = new Set<string>();
|
|
72
|
+
|
|
73
|
+
const find = (transactionId: string): PersistedPurchase | undefined =>
|
|
74
|
+
deps.snapshot().purchases.find(p => p.transactionId === transactionId);
|
|
75
|
+
|
|
76
|
+
const patch = (
|
|
77
|
+
transactionId: string,
|
|
78
|
+
changes: Partial<PersistedPurchase>,
|
|
79
|
+
): void => {
|
|
80
|
+
const snapshot = deps.snapshot();
|
|
81
|
+
deps.commit({
|
|
82
|
+
...snapshot,
|
|
83
|
+
purchases: snapshot.purchases.map(p =>
|
|
84
|
+
p.transactionId === transactionId ? { ...p, ...changes } : p,
|
|
85
|
+
),
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const clearTimer = (transactionId: string): void => {
|
|
90
|
+
const timer = timers.get(transactionId);
|
|
91
|
+
if (timer !== undefined) {
|
|
92
|
+
clearTimeoutFn(timer);
|
|
93
|
+
timers.delete(transactionId);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Attempts one finalize call.
|
|
99
|
+
*
|
|
100
|
+
* A consumable is **not** acknowledged here: it waits for the app's explicit
|
|
101
|
+
* `consume`. Acknowledging it now would tell the store the entitlement was
|
|
102
|
+
* delivered before the app has recorded the item, and a crash in between would
|
|
103
|
+
* lose a paid item with no way to recover it.
|
|
104
|
+
*/
|
|
105
|
+
const attempt = async (transactionId: string): Promise<FinalizeResult> => {
|
|
106
|
+
if (deps.disposed()) {
|
|
107
|
+
return { kind: 'failed', code: 'PAYMENT_NOT_CONFIGURED' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const record = find(transactionId);
|
|
111
|
+
if (!record) {
|
|
112
|
+
return { kind: 'unknown_transaction' };
|
|
113
|
+
}
|
|
114
|
+
if (record.finalizeState === 'done') {
|
|
115
|
+
// Idempotent by design: calling twice is a success, not an error (FR-044).
|
|
116
|
+
return { kind: 'already_finalized' };
|
|
117
|
+
}
|
|
118
|
+
if (record.productType === 'one_time_consumable') {
|
|
119
|
+
// The app owns this moment. Nothing to do until it calls consume().
|
|
120
|
+
return { kind: 'already_finalized' };
|
|
121
|
+
}
|
|
122
|
+
if (inFlight.has(transactionId)) {
|
|
123
|
+
return { kind: 'retry_scheduled', attempts: record.attempts };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
inFlight.add(transactionId);
|
|
127
|
+
patch(transactionId, { finalizeState: 'in_flight' });
|
|
128
|
+
|
|
129
|
+
let failed = false;
|
|
130
|
+
try {
|
|
131
|
+
await deps.provider.acknowledge(record.token);
|
|
132
|
+
} catch {
|
|
133
|
+
failed = true;
|
|
134
|
+
} finally {
|
|
135
|
+
// Released before scheduling the retry, deliberately. The guard exists to
|
|
136
|
+
// stop two concurrent store calls for one transaction, nothing more — if
|
|
137
|
+
// it were still held here, the next scheduled attempt would be refused by
|
|
138
|
+
// our own lock and the retry schedule would silently stall after one try.
|
|
139
|
+
inFlight.delete(transactionId);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const attempts = record.attempts + 1;
|
|
143
|
+
|
|
144
|
+
if (!failed) {
|
|
145
|
+
clearTimer(transactionId);
|
|
146
|
+
patch(transactionId, {
|
|
147
|
+
finalizeState: 'done',
|
|
148
|
+
state: 'purchased',
|
|
149
|
+
attempts,
|
|
150
|
+
});
|
|
151
|
+
deps.emit({
|
|
152
|
+
type: 'transaction_finalized',
|
|
153
|
+
productKey: record.productKey,
|
|
154
|
+
attempts,
|
|
155
|
+
});
|
|
156
|
+
return { kind: 'finalized' };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
patch(transactionId, { finalizeState: 'failed', attempts });
|
|
160
|
+
return scheduleRetry(transactionId, record.productKey, attempts);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Schedules the next attempt inside the per-session budget.
|
|
165
|
+
*
|
|
166
|
+
* Exhausting the budget is **not** giving up. The record stays `failed`, which
|
|
167
|
+
* `pendingFinalizations` still returns, so the next startup or foreground drain
|
|
168
|
+
* picks it up again. That is what keeps the package inside Play's three-day
|
|
169
|
+
* window without hammering a store that is currently refusing.
|
|
170
|
+
*/
|
|
171
|
+
const scheduleRetry = (
|
|
172
|
+
transactionId: string,
|
|
173
|
+
productKey: ProductKey,
|
|
174
|
+
attempts: number,
|
|
175
|
+
): FinalizeResult => {
|
|
176
|
+
if (deps.disposed()) {
|
|
177
|
+
return { kind: 'failed', code: 'PAYMENT_FINALIZE_FAILED' };
|
|
178
|
+
}
|
|
179
|
+
if (attempts >= deps.retry.finalize.maxAttempts) {
|
|
180
|
+
// Left queued deliberately — resumed next session, never abandoned.
|
|
181
|
+
return { kind: 'failed', code: 'PAYMENT_FINALIZE_FAILED' };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const delayMs = backoffDelayMs(attempts + 1, deps.retry.finalize);
|
|
185
|
+
clearTimer(transactionId);
|
|
186
|
+
timers.set(
|
|
187
|
+
transactionId,
|
|
188
|
+
setTimeoutFn(() => {
|
|
189
|
+
timers.delete(transactionId);
|
|
190
|
+
attempt(transactionId).catch(() => undefined);
|
|
191
|
+
}, delayMs),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
deps.emit({
|
|
195
|
+
type: 'finalize_retry_scheduled',
|
|
196
|
+
productKey,
|
|
197
|
+
attempt: attempts + 1,
|
|
198
|
+
delayMs,
|
|
199
|
+
});
|
|
200
|
+
return { kind: 'retry_scheduled', attempts };
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
async enqueue(transactionId: string): Promise<FinalizeResult> {
|
|
205
|
+
const record = find(transactionId);
|
|
206
|
+
if (!record) {
|
|
207
|
+
return { kind: 'unknown_transaction' };
|
|
208
|
+
}
|
|
209
|
+
if (record.finalizeState === 'done') {
|
|
210
|
+
return { kind: 'already_finalized' };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The write happens BEFORE the store call. This single ordering is what
|
|
214
|
+
// makes a kill between payment and acknowledgement recoverable.
|
|
215
|
+
if (record.finalizeState !== 'in_flight') {
|
|
216
|
+
patch(transactionId, { finalizeState: 'queued' });
|
|
217
|
+
}
|
|
218
|
+
return attempt(transactionId);
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
async drain(): Promise<void> {
|
|
222
|
+
// Runs regardless of whether a purchase happened this session: the record
|
|
223
|
+
// it is looking for was probably written by a session that died.
|
|
224
|
+
for (const record of pendingFinalizations(deps.snapshot())) {
|
|
225
|
+
if (deps.disposed()) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
await attempt(record.transactionId).catch(() => undefined);
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
async consume(ref: TransactionRef): Promise<FinalizeResult> {
|
|
233
|
+
const record = find(ref.transactionId);
|
|
234
|
+
if (!record) {
|
|
235
|
+
return { kind: 'unknown_transaction' };
|
|
236
|
+
}
|
|
237
|
+
if (record.state === 'consumed') {
|
|
238
|
+
// Consuming twice must grant the item once (FR-044).
|
|
239
|
+
return { kind: 'already_finalized' };
|
|
240
|
+
}
|
|
241
|
+
if (inFlight.has(ref.transactionId)) {
|
|
242
|
+
// Concurrent consume calls collapse to one, for the same reason.
|
|
243
|
+
return { kind: 'already_finalized' };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
inFlight.add(ref.transactionId);
|
|
247
|
+
patch(ref.transactionId, { finalizeState: 'in_flight' });
|
|
248
|
+
|
|
249
|
+
let failed = false;
|
|
250
|
+
try {
|
|
251
|
+
await deps.provider.consume(record.token);
|
|
252
|
+
} catch {
|
|
253
|
+
failed = true;
|
|
254
|
+
} finally {
|
|
255
|
+
// Released before scheduling, for the same reason as in `attempt`.
|
|
256
|
+
inFlight.delete(ref.transactionId);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const attempts = record.attempts + 1;
|
|
260
|
+
|
|
261
|
+
if (failed) {
|
|
262
|
+
patch(ref.transactionId, { finalizeState: 'failed', attempts });
|
|
263
|
+
return scheduleRetry(ref.transactionId, record.productKey, attempts);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
clearTimer(ref.transactionId);
|
|
267
|
+
// `consumed` is what stops it being an entitlement source, so a
|
|
268
|
+
// consumable never becomes a permanent unlock.
|
|
269
|
+
patch(ref.transactionId, {
|
|
270
|
+
state: 'consumed',
|
|
271
|
+
finalizeState: 'done',
|
|
272
|
+
attempts,
|
|
273
|
+
});
|
|
274
|
+
deps.emit({
|
|
275
|
+
type: 'transaction_consumed',
|
|
276
|
+
productKey: record.productKey,
|
|
277
|
+
});
|
|
278
|
+
return { kind: 'finalized' };
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
unfinalized(): UnfinalizedSummary[] {
|
|
282
|
+
return pendingFinalizations(deps.snapshot()).map(p => ({
|
|
283
|
+
productKey: p.productKey,
|
|
284
|
+
transactionId: p.transactionId,
|
|
285
|
+
attempts: p.attempts,
|
|
286
|
+
purchasedAt: p.purchasedAt,
|
|
287
|
+
}));
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
dispose(): void {
|
|
291
|
+
for (const timer of timers.values()) {
|
|
292
|
+
clearTimeoutFn(timer);
|
|
293
|
+
}
|
|
294
|
+
timers.clear();
|
|
295
|
+
inFlight.clear();
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|