@chipmobilesdk/rn-payment 0.0.0-stage → 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 +201 -0
- package/README.md +885 -3
- package/package.json +53 -5
- package/skills/sdk-rn-payment/SKILL.md +164 -0
- package/src/adapter/createStoreProvider.ts +117 -0
- package/src/adapter/iap-adapter.ts +547 -0
- package/src/adapter/port.ts +198 -0
- package/src/config/types.ts +295 -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 +266 -0
- package/src/diagnostics/messageKeys.ts +95 -0
- package/src/engine/engine.ts +951 -0
- package/src/engine/finalize.ts +298 -0
- package/src/engine/pending.ts +118 -0
- package/src/engine/products.ts +259 -0
- package/src/engine/purchase.ts +526 -0
- package/src/engine/subscriptions.ts +157 -0
- package/src/entitlements/aggregate.ts +242 -0
- package/src/entitlements/mapping.ts +67 -0
- package/src/entitlements/policy.ts +129 -0
- package/src/index.ts +172 -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 +274 -0
- package/src/testing/fakeStore.ts +374 -0
- package/src/testing/index.ts +10 -0
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engine: status, initialization, entitlement reads, and sync.
|
|
3
|
+
*
|
|
4
|
+
* Owns the concurrency guarantees that make three requirements structurally true
|
|
5
|
+
* rather than hoped for — one connection under concurrent init (FR-018), one
|
|
6
|
+
* store request under concurrent sync (FR-063), and a rate-limited foreground
|
|
7
|
+
* sync (PERF-002).
|
|
8
|
+
*
|
|
9
|
+
* It never imports a store type. The only thing it talks to is `ProviderPort`,
|
|
10
|
+
* which is what keeps the binding replaceable and iOS additive (FR-002, FR-007).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
assertEnvironmentSafe,
|
|
15
|
+
mayCreateRealTransaction,
|
|
16
|
+
validateConfig,
|
|
17
|
+
type ResolvedConfig,
|
|
18
|
+
type TargetPlatform,
|
|
19
|
+
} from '../config/validate';
|
|
20
|
+
import type {
|
|
21
|
+
BlockedOperation,
|
|
22
|
+
EntitlementKey,
|
|
23
|
+
FinalizeResult,
|
|
24
|
+
PaymentConfig,
|
|
25
|
+
PaymentStatus,
|
|
26
|
+
PendingPurchasesState,
|
|
27
|
+
ProductKey,
|
|
28
|
+
PurchaseOptions,
|
|
29
|
+
PurchaseResult,
|
|
30
|
+
TransactionRef,
|
|
31
|
+
UnfinalizedSummary,
|
|
32
|
+
Unsubscribe,
|
|
33
|
+
} from '../config/types';
|
|
34
|
+
import { PaymentError, redact } from '../diagnostics/errors';
|
|
35
|
+
import {
|
|
36
|
+
createCorrelationId,
|
|
37
|
+
createEmitter,
|
|
38
|
+
type PaymentEmitter,
|
|
39
|
+
type PaymentEvent,
|
|
40
|
+
type PaymentEventInput,
|
|
41
|
+
type PaymentEventListener,
|
|
42
|
+
type SyncTrigger,
|
|
43
|
+
} from '../diagnostics/events';
|
|
44
|
+
import {
|
|
45
|
+
createExclusive,
|
|
46
|
+
createRateLimiter,
|
|
47
|
+
createSingleFlight,
|
|
48
|
+
withTimeout,
|
|
49
|
+
} from '../core/mutex';
|
|
50
|
+
import { createFinalizer } from './finalize';
|
|
51
|
+
import { createProducts, type ProductQueryResult } from './products';
|
|
52
|
+
import { createPurchaseFlow } from './purchase';
|
|
53
|
+
import {
|
|
54
|
+
createSubscriptions,
|
|
55
|
+
type PriceChangeResult,
|
|
56
|
+
type ProrationMode,
|
|
57
|
+
} from './subscriptions';
|
|
58
|
+
import { backoffDelayMs } from '../core/retry';
|
|
59
|
+
import {
|
|
60
|
+
createSnapshotStore,
|
|
61
|
+
type SnapshotStore,
|
|
62
|
+
} from '../persistence/mmkv-store';
|
|
63
|
+
import {
|
|
64
|
+
emptySnapshot,
|
|
65
|
+
resolveFinalizeState,
|
|
66
|
+
type PersistedPurchase,
|
|
67
|
+
type PersistedSnapshot,
|
|
68
|
+
} from '../persistence/snapshot';
|
|
69
|
+
import {
|
|
70
|
+
aggregate,
|
|
71
|
+
diffEntitlements,
|
|
72
|
+
initialEntitlements,
|
|
73
|
+
type EntitlementMap,
|
|
74
|
+
type EntitlementState,
|
|
75
|
+
} from '../entitlements/aggregate';
|
|
76
|
+
import { productKeyForStoreId } from '../entitlements/mapping';
|
|
77
|
+
import {
|
|
78
|
+
derivePending,
|
|
79
|
+
pendingTransitions,
|
|
80
|
+
samePending,
|
|
81
|
+
UNKNOWN_PENDING,
|
|
82
|
+
} from './pending';
|
|
83
|
+
import type { ProviderPort, ProviderPurchase } from '../adapter/port';
|
|
84
|
+
|
|
85
|
+
export type SyncOutcome =
|
|
86
|
+
| { ok: true; restoredCount: number; revokedCount: number }
|
|
87
|
+
| { ok: false; error: PaymentError };
|
|
88
|
+
|
|
89
|
+
export type RestoreOutcome =
|
|
90
|
+
| { ok: true; restored: true; entitlements: EntitlementKey[] }
|
|
91
|
+
/** Distinct from a failure so an app never shows a false success (FR-061). */
|
|
92
|
+
| { ok: true; restored: false }
|
|
93
|
+
| { ok: false; error: PaymentError };
|
|
94
|
+
|
|
95
|
+
export type InitOutcome =
|
|
96
|
+
| { ok: true }
|
|
97
|
+
| { ok: false; error: PaymentError };
|
|
98
|
+
|
|
99
|
+
export interface EngineDeps {
|
|
100
|
+
/** Injected so the fake store is a drop-in replacement (NFR-007). */
|
|
101
|
+
provider: ProviderPort;
|
|
102
|
+
snapshotStore?: SnapshotStore;
|
|
103
|
+
platform?: TargetPlatform;
|
|
104
|
+
now?: () => number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface PaymentEngine {
|
|
108
|
+
initialize(): Promise<InitOutcome>;
|
|
109
|
+
cleanup(): void;
|
|
110
|
+
|
|
111
|
+
getStatus(): PaymentStatus;
|
|
112
|
+
subscribeStatus(listener: (status: PaymentStatus) => void): Unsubscribe;
|
|
113
|
+
|
|
114
|
+
getEntitlement(key: EntitlementKey): EntitlementState;
|
|
115
|
+
getAllEntitlements(): EntitlementMap;
|
|
116
|
+
subscribeEntitlements(
|
|
117
|
+
listener: (entitlements: EntitlementMap) => void,
|
|
118
|
+
): Unsubscribe;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Purchases whose money has not arrived, in this session or an earlier one
|
|
122
|
+
* (FR-101). Synchronous and in-memory, like `getEntitlement`. Added in 0.2.0.
|
|
123
|
+
*/
|
|
124
|
+
getPendingPurchases(): PendingPurchasesState;
|
|
125
|
+
/** Pushed only when the set, its status, or its staleness changes (FR-101). */
|
|
126
|
+
subscribePendingPurchases(
|
|
127
|
+
listener: (pending: PendingPurchasesState) => void,
|
|
128
|
+
): Unsubscribe;
|
|
129
|
+
|
|
130
|
+
sync(trigger?: SyncTrigger): Promise<SyncOutcome>;
|
|
131
|
+
restore(): Promise<RestoreOutcome>;
|
|
132
|
+
getLastSyncedAt(): number | undefined;
|
|
133
|
+
clearLocalData(): Promise<void>;
|
|
134
|
+
|
|
135
|
+
subscribeEvents(listener: PaymentEventListener): Unsubscribe;
|
|
136
|
+
/** Exposed for diagnostics and tests; not part of the public package surface. */
|
|
137
|
+
resolvedConfig(): ResolvedConfig;
|
|
138
|
+
nextCorrelationId(): string;
|
|
139
|
+
emitter(): PaymentEmitter;
|
|
140
|
+
snapshot(): PersistedSnapshot;
|
|
141
|
+
commitSnapshot(next: PersistedSnapshot): void;
|
|
142
|
+
mayCreateRealTransaction(): boolean;
|
|
143
|
+
recordBlockedOperation(productKey: ProductKey): void;
|
|
144
|
+
|
|
145
|
+
// --- Purchase and finalization (US2, US3, US5) ---------------------------
|
|
146
|
+
purchase(
|
|
147
|
+
productKey: ProductKey,
|
|
148
|
+
options?: PurchaseOptions,
|
|
149
|
+
): Promise<PurchaseResult>;
|
|
150
|
+
/** App-triggered consumption of a consumable (FR-043). */
|
|
151
|
+
consume(ref: TransactionRef): Promise<FinalizeResult>;
|
|
152
|
+
/** Paid-but-unsettled transactions, so the condition is observable (FR-045). */
|
|
153
|
+
getUnfinalized(): UnfinalizedSummary[];
|
|
154
|
+
|
|
155
|
+
// --- Products and pricing (US11) -----------------------------------------
|
|
156
|
+
getProducts(productKeys: ProductKey[]): Promise<ProductQueryResult>;
|
|
157
|
+
refreshProducts(productKeys: ProductKey[]): Promise<ProductQueryResult>;
|
|
158
|
+
|
|
159
|
+
// --- Subscriptions (US12) ------------------------------------------------
|
|
160
|
+
changePlan(
|
|
161
|
+
fromProductKey: ProductKey,
|
|
162
|
+
toProductKey: ProductKey,
|
|
163
|
+
mode: ProrationMode,
|
|
164
|
+
options?: PurchaseOptions,
|
|
165
|
+
): Promise<PurchaseResult>;
|
|
166
|
+
/** Opens the **store's** management screen; never a self-built one (FR-066). */
|
|
167
|
+
openSubscriptionManagement(productKey?: ProductKey): Promise<void>;
|
|
168
|
+
confirmPriceChange(productKey: ProductKey): Promise<PriceChangeResult>;
|
|
169
|
+
|
|
170
|
+
// --- Lifecycle -----------------------------------------------------------
|
|
171
|
+
/**
|
|
172
|
+
* Call when the app returns to the foreground (FR-059).
|
|
173
|
+
*
|
|
174
|
+
* Left to the app rather than subscribing to `AppState` internally: the package
|
|
175
|
+
* must not decide what "foreground" means for a host that may be backgrounded
|
|
176
|
+
* by its own modal, a biometric prompt, or the store's purchase sheet. The
|
|
177
|
+
* engine rate-limits it regardless (PERF-002).
|
|
178
|
+
*/
|
|
179
|
+
handleForeground(): Promise<void>;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function createEngine(
|
|
183
|
+
config: PaymentConfig,
|
|
184
|
+
deps: EngineDeps,
|
|
185
|
+
): PaymentEngine {
|
|
186
|
+
// Both run before anything connects: a bad catalog or an unsafe environment
|
|
187
|
+
// must fail at configuration time, not when a user taps Buy.
|
|
188
|
+
assertEnvironmentSafe(config);
|
|
189
|
+
const resolved = validateConfig(config, deps.platform ?? 'android');
|
|
190
|
+
|
|
191
|
+
const now = deps.now ?? (() => Date.now());
|
|
192
|
+
const provider = deps.provider;
|
|
193
|
+
const store = deps.snapshotStore ?? createSnapshotStore();
|
|
194
|
+
// A listener that throws is a consumer bug, so it is routed to the app's own
|
|
195
|
+
// logger rather than back into the event stream (FR-080, FR-081). Swallowing it
|
|
196
|
+
// entirely would hide a broken analytics adapter indefinitely.
|
|
197
|
+
const emitter = createEmitter((error, event) => {
|
|
198
|
+
config.logger?.error?.('payment event listener threw', {
|
|
199
|
+
eventType: event.type,
|
|
200
|
+
reason: redact(error),
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
const correlationCounter = { next: 1 };
|
|
204
|
+
|
|
205
|
+
const initFlight = createSingleFlight<InitOutcome>();
|
|
206
|
+
const syncFlight = createSingleFlight<SyncOutcome>();
|
|
207
|
+
const foregroundLimiter = createRateLimiter(
|
|
208
|
+
resolved.foregroundSyncMinIntervalMs,
|
|
209
|
+
now,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
let disposed = false;
|
|
213
|
+
let hasSynced = false;
|
|
214
|
+
let currentSnapshot: PersistedSnapshot = emptySnapshot(config.scopeToken);
|
|
215
|
+
let entitlements: EntitlementMap = initialEntitlements(resolved);
|
|
216
|
+
let status: PaymentStatus = {
|
|
217
|
+
kind: resolved.enabled ? 'not_started' : 'disabled',
|
|
218
|
+
blockedOperations: [],
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
let pending: PendingPurchasesState = UNKNOWN_PENDING;
|
|
222
|
+
|
|
223
|
+
const statusListeners = new Set<(status: PaymentStatus) => void>();
|
|
224
|
+
const entitlementListeners = new Set<(map: EntitlementMap) => void>();
|
|
225
|
+
const pendingListeners = new Set<(pending: PendingPurchasesState) => void>();
|
|
226
|
+
let connectionSub: Unsubscribe | undefined;
|
|
227
|
+
let purchaseSub: Unsubscribe | undefined;
|
|
228
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
229
|
+
let reconnectAttempts = 0;
|
|
230
|
+
// Exclusive rather than single-flight: a second purchase must be refused, not
|
|
231
|
+
// joined onto the first, and never queued (FR-031).
|
|
232
|
+
const purchaseLock = createExclusive();
|
|
233
|
+
|
|
234
|
+
const emit = (event: PaymentEventInput) => {
|
|
235
|
+
emitter.emit({ ...event, timestamp: event.timestamp ?? now() } as PaymentEvent);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const setStatus = (next: Partial<PaymentStatus>): void => {
|
|
239
|
+
if (disposed) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
status = { ...status, ...next };
|
|
243
|
+
emit({
|
|
244
|
+
type: 'initialization',
|
|
245
|
+
status: status.kind,
|
|
246
|
+
unavailableReason: status.unavailableReason,
|
|
247
|
+
retryable: status.retryable,
|
|
248
|
+
});
|
|
249
|
+
for (const listener of [...statusListeners]) {
|
|
250
|
+
// A throwing status listener must not break the state machine (FR-081).
|
|
251
|
+
try {
|
|
252
|
+
listener(status);
|
|
253
|
+
} catch {
|
|
254
|
+
/* reported through the emitter's own guard */
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Recomputes entitlements and emits only genuine transitions.
|
|
261
|
+
*
|
|
262
|
+
* Diff-based emission is what gives the exactly-once guarantees: a renewal
|
|
263
|
+
* that changes nothing produces no event (FR-068), and an entitlement with
|
|
264
|
+
* several sources fires once, not once per source (FR-079).
|
|
265
|
+
*/
|
|
266
|
+
const recompute = (): void => {
|
|
267
|
+
if (disposed) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const previous = entitlements;
|
|
271
|
+
entitlements = aggregate({
|
|
272
|
+
config: resolved,
|
|
273
|
+
snapshot: currentSnapshot,
|
|
274
|
+
hasSynced,
|
|
275
|
+
reportsGraceAndHoldDistinctly:
|
|
276
|
+
provider.capabilities().reportsGraceAndHoldDistinctly,
|
|
277
|
+
now: now(),
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
for (const change of diffEntitlements(previous, entitlements)) {
|
|
281
|
+
if (change.next === 'entitled') {
|
|
282
|
+
emit({
|
|
283
|
+
type: 'entitlement_granted',
|
|
284
|
+
entitlementKey: change.key,
|
|
285
|
+
sources: change.sources,
|
|
286
|
+
});
|
|
287
|
+
} else if (change.previous === 'entitled') {
|
|
288
|
+
emit({
|
|
289
|
+
type: 'entitlement_revoked',
|
|
290
|
+
entitlementKey: change.key,
|
|
291
|
+
reason: 'revoked_by_store',
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
for (const listener of [...entitlementListeners]) {
|
|
297
|
+
try {
|
|
298
|
+
listener(entitlements);
|
|
299
|
+
} catch {
|
|
300
|
+
/* a broken consumer callback must not stop the others (FR-081) */
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
publishPending(
|
|
305
|
+
derivePending({
|
|
306
|
+
config: resolved,
|
|
307
|
+
snapshot: currentSnapshot,
|
|
308
|
+
hasSynced,
|
|
309
|
+
now: now(),
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Hands listeners the pending set, but only when it changed (FR-101).
|
|
316
|
+
*
|
|
317
|
+
* Unlike entitlements this is diffed, because a sync that changes nothing is
|
|
318
|
+
* the common case and an app rendering "payment pending" has no use for a
|
|
319
|
+
* re-render that says the same thing.
|
|
320
|
+
*/
|
|
321
|
+
const publishPending = (next: PendingPurchasesState): void => {
|
|
322
|
+
if (samePending(pending, next)) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
pending = next;
|
|
326
|
+
for (const listener of [...pendingListeners]) {
|
|
327
|
+
try {
|
|
328
|
+
listener(pending);
|
|
329
|
+
} catch {
|
|
330
|
+
/* a broken consumer callback must not stop the others (FR-081) */
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const commit = (next: PersistedSnapshot): void => {
|
|
336
|
+
// Read before the swap: a transition is "pending before, not pending now",
|
|
337
|
+
// and only the commit sees both sides (FR-104).
|
|
338
|
+
const ended = pendingTransitions(currentSnapshot, next, resolved, transactionId =>
|
|
339
|
+
provider.pendingEndReason?.(transactionId),
|
|
340
|
+
);
|
|
341
|
+
currentSnapshot = next;
|
|
342
|
+
store.save(next);
|
|
343
|
+
// Before `recompute`, so `settled` precedes the `entitlement_granted` it
|
|
344
|
+
// causes — an app that hides "payment pending" on the first event never
|
|
345
|
+
// shows the grant and the pending row at once (FR-102).
|
|
346
|
+
for (const transition of ended) {
|
|
347
|
+
emit({
|
|
348
|
+
type: 'purchase_pending_resolved',
|
|
349
|
+
productKey: transition.productKey,
|
|
350
|
+
outcome: transition.outcome,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
recompute();
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Loads the persisted snapshot and makes it trustworthy.
|
|
358
|
+
*
|
|
359
|
+
* Shared by both initialization paths so they cannot drift. `hasSynced` is the
|
|
360
|
+
* subtle part: a record carrying a `lastSyncedAt` was written by a sync that
|
|
361
|
+
* genuinely succeeded, so it is a basis for asserting entitlement. Without
|
|
362
|
+
* setting it, a build with selling disabled would report `unknown` forever,
|
|
363
|
+
* because no sync will ever run to mark the snapshot good (FR-012, FR-053).
|
|
364
|
+
*/
|
|
365
|
+
const hydrate = (): void => {
|
|
366
|
+
currentSnapshot = store.load(config.scopeToken);
|
|
367
|
+
if (currentSnapshot.lastSyncedAt !== undefined) {
|
|
368
|
+
hasSynced = true;
|
|
369
|
+
}
|
|
370
|
+
if (!store.isDurable()) {
|
|
371
|
+
emit({
|
|
372
|
+
type: 'config_warning',
|
|
373
|
+
detail:
|
|
374
|
+
'Local storage unavailable; entitlements will not survive restart',
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
recompute();
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
/** Translates store purchases into persisted records, dropping unknown products. */
|
|
381
|
+
const toPersisted = (purchases: ProviderPurchase[]): PersistedPurchase[] => {
|
|
382
|
+
const records: PersistedPurchase[] = [];
|
|
383
|
+
for (const purchase of purchases) {
|
|
384
|
+
const productKey = productKeyForStoreId(resolved, purchase.storeProductId);
|
|
385
|
+
if (!productKey) {
|
|
386
|
+
// Legitimately possible: a user owns something from a build whose
|
|
387
|
+
// catalog has since changed. Ignored rather than treated as impossible.
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const entry = resolved.entries.get(productKey);
|
|
391
|
+
if (!entry) {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
const existing = currentSnapshot.purchases.find(
|
|
395
|
+
p => p.transactionId === purchase.transactionId,
|
|
396
|
+
);
|
|
397
|
+
if (purchase.state === 'unspecified') {
|
|
398
|
+
// The store reported a state it could not name. That is no basis for
|
|
399
|
+
// granting, finalizing, or ending anything: a new transaction is left
|
|
400
|
+
// out until the store says what it is, and a known one keeps what we
|
|
401
|
+
// already knew. 0.1.0 recorded it as `purchased` — a grant, and an
|
|
402
|
+
// acknowledgement of a purchase nobody confirmed.
|
|
403
|
+
if (existing) {
|
|
404
|
+
records.push(existing);
|
|
405
|
+
}
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
records.push({
|
|
409
|
+
transactionId: purchase.transactionId,
|
|
410
|
+
productKey,
|
|
411
|
+
productType: entry.productType,
|
|
412
|
+
token: purchase.token,
|
|
413
|
+
state: purchase.state === 'pending' ? 'pending' : 'purchased',
|
|
414
|
+
purchasedAt: purchase.purchasedAt,
|
|
415
|
+
// Preserve finalize progress across syncs: a sync must never reset a
|
|
416
|
+
// queued acknowledgement and lose the paid transaction.
|
|
417
|
+
finalizeState: resolveFinalizeState(purchase, existing),
|
|
418
|
+
attempts: existing?.attempts ?? 0,
|
|
419
|
+
validationState: existing?.validationState ?? 'not_required',
|
|
420
|
+
expiresAt: purchase.expiresAt,
|
|
421
|
+
willAutoRenew: purchase.willAutoRenew,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
return records;
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const scheduleReconnect = (): void => {
|
|
428
|
+
if (disposed || reconnectAttempts >= resolved.retry.reconnect.maxAttempts) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const attempt = reconnectAttempts + 1;
|
|
432
|
+
const delay = backoffDelayMs(attempt, resolved.retry.reconnect);
|
|
433
|
+
reconnectTimer = setTimeout(() => {
|
|
434
|
+
reconnectTimer = undefined;
|
|
435
|
+
if (disposed) {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
reconnectAttempts = attempt;
|
|
439
|
+
setStatus({ kind: 'connecting', reconnectAttempts });
|
|
440
|
+
reconnectOnce().catch(() => undefined);
|
|
441
|
+
}, delay);
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* One reconnect attempt: a full teardown-then-connect cycle, deliberately.
|
|
446
|
+
*
|
|
447
|
+
* The upstream binding's own `reconnect()` has reported success without
|
|
448
|
+
* rebuilding anything (openiap#408), so we never trust a shortcut here.
|
|
449
|
+
*/
|
|
450
|
+
const reconnectOnce = async (): Promise<void> => {
|
|
451
|
+
try {
|
|
452
|
+
await provider.disconnect();
|
|
453
|
+
} catch {
|
|
454
|
+
/* already down; nothing to tear down */
|
|
455
|
+
}
|
|
456
|
+
const outcome = await provider.connect();
|
|
457
|
+
if (disposed) {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (outcome.ok) {
|
|
461
|
+
reconnectAttempts = 0;
|
|
462
|
+
setStatus({ kind: 'ready', reconnectAttempts: 0 });
|
|
463
|
+
} else {
|
|
464
|
+
scheduleReconnect();
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const finalizer = createFinalizer({
|
|
469
|
+
provider,
|
|
470
|
+
retry: resolved.retry,
|
|
471
|
+
snapshot: () => currentSnapshot,
|
|
472
|
+
commit,
|
|
473
|
+
emit,
|
|
474
|
+
now,
|
|
475
|
+
disposed: () => disposed,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
const products = createProducts({
|
|
479
|
+
resolved,
|
|
480
|
+
provider,
|
|
481
|
+
emit,
|
|
482
|
+
now,
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const purchaseFlow = createPurchaseFlow({
|
|
486
|
+
config,
|
|
487
|
+
resolved,
|
|
488
|
+
provider,
|
|
489
|
+
finalizer,
|
|
490
|
+
snapshot: () => currentSnapshot,
|
|
491
|
+
commit,
|
|
492
|
+
emit,
|
|
493
|
+
now,
|
|
494
|
+
nextCorrelationId: () => createCorrelationId(correlationCounter),
|
|
495
|
+
statusKind: () => status.kind,
|
|
496
|
+
mayCreateRealTransaction: () => mayCreateRealTransaction(config),
|
|
497
|
+
recordBlockedOperation: productKey =>
|
|
498
|
+
engine.recordBlockedOperation(productKey),
|
|
499
|
+
// `already_owned` triggers a re-sync rather than surfacing an error, which is
|
|
500
|
+
// how a reinstall repairs itself (FR-033). Fire-and-forget: the caller gets
|
|
501
|
+
// its typed result now and the entitlement arrives via the change signal.
|
|
502
|
+
requestSync: trigger => {
|
|
503
|
+
engine.sync(trigger).catch(() => undefined);
|
|
504
|
+
},
|
|
505
|
+
runExclusive: operation => purchaseLock.run(operation),
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
const subscriptions = createSubscriptions({
|
|
509
|
+
resolved,
|
|
510
|
+
provider,
|
|
511
|
+
snapshot: () => currentSnapshot,
|
|
512
|
+
purchase: (productKey, options) =>
|
|
513
|
+
purchaseFlow.purchase(productKey, options),
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Watches for store-pushed purchase updates.
|
|
518
|
+
*
|
|
519
|
+
* This is the channel a pending purchase settling between sessions arrives on
|
|
520
|
+
* (FR-039), so it must stay subscribed for the whole session, not only while a
|
|
521
|
+
* purchase is open.
|
|
522
|
+
*/
|
|
523
|
+
const watchPurchases = (): void => {
|
|
524
|
+
purchaseSub = provider.observePurchases(purchase => {
|
|
525
|
+
if (disposed) {
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
purchaseFlow.ingest(purchase).catch(() => undefined);
|
|
529
|
+
});
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const watchConnection = (): void => {
|
|
533
|
+
connectionSub = provider.observeConnection((connected, reason) => {
|
|
534
|
+
if (disposed) {
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
emit({
|
|
538
|
+
type: 'connection_changed',
|
|
539
|
+
connected,
|
|
540
|
+
reason,
|
|
541
|
+
attempt: reconnectAttempts,
|
|
542
|
+
});
|
|
543
|
+
if (!connected && status.kind === 'ready') {
|
|
544
|
+
setStatus({ kind: 'connecting' });
|
|
545
|
+
scheduleReconnect();
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
const engine: PaymentEngine = {
|
|
551
|
+
async initialize(): Promise<InitOutcome> {
|
|
552
|
+
if (!resolved.enabled) {
|
|
553
|
+
// Selling is off, but entitlements already held stay readable — turning
|
|
554
|
+
// off sales is not the same as taking away what someone bought (FR-012).
|
|
555
|
+
// Hydration runs here too: without it a previously granted entitlement
|
|
556
|
+
// would read `unknown` forever in a build with selling disabled, because
|
|
557
|
+
// no sync will ever run to mark the snapshot trustworthy.
|
|
558
|
+
setStatus({ kind: 'disabled' });
|
|
559
|
+
hydrate();
|
|
560
|
+
return {
|
|
561
|
+
ok: false,
|
|
562
|
+
error: new PaymentError({
|
|
563
|
+
code: 'PAYMENT_DISABLED',
|
|
564
|
+
message: 'Payments are disabled by configuration',
|
|
565
|
+
operation: 'initialize',
|
|
566
|
+
}),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return initFlight.run(async () => {
|
|
571
|
+
if (disposed) {
|
|
572
|
+
return {
|
|
573
|
+
ok: false as const,
|
|
574
|
+
error: new PaymentError({
|
|
575
|
+
code: 'PAYMENT_NOT_CONFIGURED',
|
|
576
|
+
message: 'Engine has been cleaned up',
|
|
577
|
+
operation: 'initialize',
|
|
578
|
+
}),
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
setStatus({ kind: 'connecting' });
|
|
583
|
+
|
|
584
|
+
// Hydrate before connecting so a warm start can answer entitlement
|
|
585
|
+
// questions from the last good sync while the network is still in
|
|
586
|
+
// flight (FR-053).
|
|
587
|
+
hydrate();
|
|
588
|
+
|
|
589
|
+
const outcome = await withTimeout(
|
|
590
|
+
provider.connect(),
|
|
591
|
+
resolved.timeouts.connectMs,
|
|
592
|
+
() =>
|
|
593
|
+
new PaymentError({
|
|
594
|
+
code: 'PAYMENT_TIMEOUT',
|
|
595
|
+
message: 'Connecting to the billing service timed out',
|
|
596
|
+
operation: 'initialize',
|
|
597
|
+
retryable: true,
|
|
598
|
+
}),
|
|
599
|
+
).catch((error: PaymentError) => ({
|
|
600
|
+
ok: false as const,
|
|
601
|
+
reason: 'store_error' as const,
|
|
602
|
+
retryable: error.retryable,
|
|
603
|
+
}));
|
|
604
|
+
|
|
605
|
+
if (!outcome.ok) {
|
|
606
|
+
if (outcome.reason === 'store_error') {
|
|
607
|
+
setStatus({ kind: 'failed', retryable: outcome.retryable });
|
|
608
|
+
return {
|
|
609
|
+
ok: false as const,
|
|
610
|
+
error: new PaymentError({
|
|
611
|
+
code: 'PAYMENT_STORE_ERROR',
|
|
612
|
+
message: 'Could not connect to the billing service',
|
|
613
|
+
operation: 'initialize',
|
|
614
|
+
retryable: outcome.retryable,
|
|
615
|
+
}),
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
// No billing service, an outdated one, or an unsupported platform:
|
|
619
|
+
// each distinguishable, and none of them may break the host app
|
|
620
|
+
// (FR-021, FR-023).
|
|
621
|
+
setStatus({
|
|
622
|
+
kind: 'unavailable',
|
|
623
|
+
unavailableReason: outcome.reason,
|
|
624
|
+
retryable: outcome.retryable,
|
|
625
|
+
});
|
|
626
|
+
return {
|
|
627
|
+
ok: false as const,
|
|
628
|
+
error: new PaymentError({
|
|
629
|
+
code:
|
|
630
|
+
outcome.reason === 'platform_unsupported'
|
|
631
|
+
? 'PAYMENT_UNSUPPORTED_PLATFORM'
|
|
632
|
+
: 'PAYMENT_UNAVAILABLE',
|
|
633
|
+
message: `Billing unavailable: ${outcome.reason}`,
|
|
634
|
+
operation: 'initialize',
|
|
635
|
+
retryable: outcome.retryable,
|
|
636
|
+
}),
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
watchConnection();
|
|
641
|
+
watchPurchases();
|
|
642
|
+
setStatus({ kind: 'ready', reconnectAttempts: 0 });
|
|
643
|
+
await engine.sync('initialize');
|
|
644
|
+
// Drain after syncing, so a transaction the store still reports as owned
|
|
645
|
+
// has been re-queued first. This is the step that catches a purchase
|
|
646
|
+
// the previous session paid for but was killed before acknowledging
|
|
647
|
+
// (FR-042).
|
|
648
|
+
await finalizer.drain();
|
|
649
|
+
return { ok: true as const };
|
|
650
|
+
});
|
|
651
|
+
},
|
|
652
|
+
|
|
653
|
+
cleanup(): void {
|
|
654
|
+
disposed = true;
|
|
655
|
+
connectionSub?.();
|
|
656
|
+
connectionSub = undefined;
|
|
657
|
+
purchaseSub?.();
|
|
658
|
+
purchaseSub = undefined;
|
|
659
|
+
finalizer.dispose();
|
|
660
|
+
if (reconnectTimer !== undefined) {
|
|
661
|
+
clearTimeout(reconnectTimer);
|
|
662
|
+
reconnectTimer = undefined;
|
|
663
|
+
}
|
|
664
|
+
statusListeners.clear();
|
|
665
|
+
entitlementListeners.clear();
|
|
666
|
+
pendingListeners.clear();
|
|
667
|
+
// Disarmed last: after this nothing is emitted, and a late store callback
|
|
668
|
+
// is dropped safely (FR-082).
|
|
669
|
+
emitter.dispose();
|
|
670
|
+
provider.disconnect().catch(() => undefined);
|
|
671
|
+
},
|
|
672
|
+
|
|
673
|
+
getStatus: () => status,
|
|
674
|
+
|
|
675
|
+
subscribeStatus(listener): Unsubscribe {
|
|
676
|
+
if (disposed) {
|
|
677
|
+
return () => undefined;
|
|
678
|
+
}
|
|
679
|
+
statusListeners.add(listener);
|
|
680
|
+
let removed = false;
|
|
681
|
+
return () => {
|
|
682
|
+
if (removed) {
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
removed = true;
|
|
686
|
+
statusListeners.delete(listener);
|
|
687
|
+
};
|
|
688
|
+
},
|
|
689
|
+
|
|
690
|
+
/** Synchronous, in-memory, zero I/O — it is read on render paths (PERF-001). */
|
|
691
|
+
getEntitlement(key): EntitlementState {
|
|
692
|
+
return (
|
|
693
|
+
entitlements[key] ?? {
|
|
694
|
+
key,
|
|
695
|
+
status: 'unknown',
|
|
696
|
+
sources: [],
|
|
697
|
+
isStale: false,
|
|
698
|
+
}
|
|
699
|
+
);
|
|
700
|
+
},
|
|
701
|
+
|
|
702
|
+
getAllEntitlements: () => entitlements,
|
|
703
|
+
|
|
704
|
+
getPendingPurchases: () => pending,
|
|
705
|
+
|
|
706
|
+
subscribePendingPurchases(listener): Unsubscribe {
|
|
707
|
+
if (disposed) {
|
|
708
|
+
return () => undefined;
|
|
709
|
+
}
|
|
710
|
+
pendingListeners.add(listener);
|
|
711
|
+
let removed = false;
|
|
712
|
+
return () => {
|
|
713
|
+
if (removed) {
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
removed = true;
|
|
717
|
+
pendingListeners.delete(listener);
|
|
718
|
+
};
|
|
719
|
+
},
|
|
720
|
+
|
|
721
|
+
subscribeEntitlements(listener): Unsubscribe {
|
|
722
|
+
if (disposed) {
|
|
723
|
+
return () => undefined;
|
|
724
|
+
}
|
|
725
|
+
entitlementListeners.add(listener);
|
|
726
|
+
let removed = false;
|
|
727
|
+
return () => {
|
|
728
|
+
if (removed) {
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
removed = true;
|
|
732
|
+
entitlementListeners.delete(listener);
|
|
733
|
+
};
|
|
734
|
+
},
|
|
735
|
+
|
|
736
|
+
async sync(trigger: SyncTrigger = 'manual'): Promise<SyncOutcome> {
|
|
737
|
+
if (disposed || !resolved.enabled) {
|
|
738
|
+
return {
|
|
739
|
+
ok: false,
|
|
740
|
+
error: new PaymentError({
|
|
741
|
+
code: 'PAYMENT_DISABLED',
|
|
742
|
+
message: 'Payments are disabled',
|
|
743
|
+
operation: 'sync',
|
|
744
|
+
}),
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (trigger === 'foreground' && !foregroundLimiter.tryAcquire()) {
|
|
749
|
+
// Rejected rather than deferred: the next foreground event will try
|
|
750
|
+
// again, whereas queueing would fire a burst once the interval elapsed.
|
|
751
|
+
return { ok: true, restoredCount: 0, revokedCount: 0 };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Concurrent callers join one flight, so repeated syncs make exactly one
|
|
755
|
+
// store request (FR-063).
|
|
756
|
+
return syncFlight.run(async () => {
|
|
757
|
+
try {
|
|
758
|
+
const owned = await withTimeout(
|
|
759
|
+
provider.queryOwned(),
|
|
760
|
+
resolved.timeouts.syncMs,
|
|
761
|
+
() =>
|
|
762
|
+
new PaymentError({
|
|
763
|
+
code: 'PAYMENT_TIMEOUT',
|
|
764
|
+
message: 'Syncing entitlements timed out',
|
|
765
|
+
operation: 'sync',
|
|
766
|
+
retryable: true,
|
|
767
|
+
}),
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
const before = new Set(
|
|
771
|
+
currentSnapshot.purchases
|
|
772
|
+
.filter(p => p.state === 'purchased')
|
|
773
|
+
.map(p => p.transactionId),
|
|
774
|
+
);
|
|
775
|
+
const records = toPersisted(owned);
|
|
776
|
+
const after = new Set(
|
|
777
|
+
records.filter(p => p.state === 'purchased').map(p => p.transactionId),
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
// A transaction the store no longer reports is revoked — a refund.
|
|
781
|
+
// The cache must not resurrect it (FR-064, FR-055).
|
|
782
|
+
let revokedCount = 0;
|
|
783
|
+
for (const id of before) {
|
|
784
|
+
if (!after.has(id)) {
|
|
785
|
+
revokedCount += 1;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
let restoredCount = 0;
|
|
789
|
+
for (const id of after) {
|
|
790
|
+
if (!before.has(id)) {
|
|
791
|
+
restoredCount += 1;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
hasSynced = true;
|
|
796
|
+
commit({
|
|
797
|
+
...currentSnapshot,
|
|
798
|
+
purchases: records,
|
|
799
|
+
lastSyncedAt: now(),
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
emit({
|
|
803
|
+
type: 'sync_completed',
|
|
804
|
+
restoredCount,
|
|
805
|
+
revokedCount,
|
|
806
|
+
trigger,
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
// Every sync is also a chance to settle what a previous session left
|
|
810
|
+
// owed. A pending purchase that completed while the app was closed
|
|
811
|
+
// arrives here, and so does a transaction whose acknowledgement failed
|
|
812
|
+
// last time (FR-039, FR-042).
|
|
813
|
+
await finalizer.drain();
|
|
814
|
+
|
|
815
|
+
// And a chance to settle a backend decision we had to defer. Done on
|
|
816
|
+
// sync rather than on a timer of ours, so recovery follows the app's
|
|
817
|
+
// own rhythm (FR-072a).
|
|
818
|
+
await purchaseFlow.revalidate();
|
|
819
|
+
|
|
820
|
+
return { ok: true as const, restoredCount, revokedCount };
|
|
821
|
+
} catch (error) {
|
|
822
|
+
const paymentError =
|
|
823
|
+
error instanceof PaymentError
|
|
824
|
+
? error
|
|
825
|
+
: new PaymentError({
|
|
826
|
+
code: 'PAYMENT_STORE_ERROR',
|
|
827
|
+
message: 'Sync failed',
|
|
828
|
+
operation: 'sync',
|
|
829
|
+
retryable: true,
|
|
830
|
+
});
|
|
831
|
+
// A failed sync leaves the previous snapshot in place, so offline
|
|
832
|
+
// entitlements survive rather than being wiped (FR-053).
|
|
833
|
+
emit({
|
|
834
|
+
type: 'internal_error',
|
|
835
|
+
code: paymentError.code,
|
|
836
|
+
operation: 'sync',
|
|
837
|
+
});
|
|
838
|
+
return { ok: false as const, error: paymentError };
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
},
|
|
842
|
+
|
|
843
|
+
async restore(): Promise<RestoreOutcome> {
|
|
844
|
+
const outcome = await engine.sync('manual');
|
|
845
|
+
if (!outcome.ok) {
|
|
846
|
+
return { ok: false, error: outcome.error };
|
|
847
|
+
}
|
|
848
|
+
const held = Object.values(entitlements)
|
|
849
|
+
.filter(e => e.status === 'entitled')
|
|
850
|
+
.map(e => e.key);
|
|
851
|
+
// "Nothing found" is a distinct success, so an app never claims to have
|
|
852
|
+
// restored purchases that do not exist (FR-061).
|
|
853
|
+
return held.length > 0
|
|
854
|
+
? { ok: true, restored: true, entitlements: held }
|
|
855
|
+
: { ok: true, restored: false };
|
|
856
|
+
},
|
|
857
|
+
|
|
858
|
+
getLastSyncedAt: () => currentSnapshot.lastSyncedAt,
|
|
859
|
+
|
|
860
|
+
async clearLocalData(): Promise<void> {
|
|
861
|
+
store.clearAll();
|
|
862
|
+
currentSnapshot = emptySnapshot(config.scopeToken);
|
|
863
|
+
hasSynced = false;
|
|
864
|
+
// Back to `unknown`, not `not_entitled`: after a logout the package has no
|
|
865
|
+
// basis for either claim until the next sync.
|
|
866
|
+
entitlements = initialEntitlements(resolved);
|
|
867
|
+
for (const listener of [...entitlementListeners]) {
|
|
868
|
+
try {
|
|
869
|
+
listener(entitlements);
|
|
870
|
+
} catch {
|
|
871
|
+
/* ignore */
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
// Same reasoning, and no `purchase_pending_resolved`: the records were
|
|
875
|
+
// discarded locally, not ended at the store.
|
|
876
|
+
publishPending(UNKNOWN_PENDING);
|
|
877
|
+
},
|
|
878
|
+
|
|
879
|
+
subscribeEvents: listener => emitter.subscribe(listener),
|
|
880
|
+
resolvedConfig: () => resolved,
|
|
881
|
+
nextCorrelationId: () => createCorrelationId(correlationCounter),
|
|
882
|
+
emitter: () => emitter,
|
|
883
|
+
snapshot: () => currentSnapshot,
|
|
884
|
+
commitSnapshot: commit,
|
|
885
|
+
mayCreateRealTransaction: () => mayCreateRealTransaction(config),
|
|
886
|
+
|
|
887
|
+
purchase(productKey, options) {
|
|
888
|
+
return purchaseFlow.purchase(productKey, options);
|
|
889
|
+
},
|
|
890
|
+
|
|
891
|
+
consume(ref) {
|
|
892
|
+
return finalizer.consume(ref);
|
|
893
|
+
},
|
|
894
|
+
|
|
895
|
+
getUnfinalized() {
|
|
896
|
+
return finalizer.unfinalized();
|
|
897
|
+
},
|
|
898
|
+
|
|
899
|
+
getProducts(productKeys) {
|
|
900
|
+
return products.get(productKeys);
|
|
901
|
+
},
|
|
902
|
+
|
|
903
|
+
refreshProducts(productKeys) {
|
|
904
|
+
return products.refresh(productKeys);
|
|
905
|
+
},
|
|
906
|
+
|
|
907
|
+
changePlan(fromProductKey, toProductKey, mode, options) {
|
|
908
|
+
return subscriptions.changePlan(
|
|
909
|
+
fromProductKey,
|
|
910
|
+
toProductKey,
|
|
911
|
+
mode,
|
|
912
|
+
options,
|
|
913
|
+
);
|
|
914
|
+
},
|
|
915
|
+
|
|
916
|
+
openSubscriptionManagement(productKey) {
|
|
917
|
+
return subscriptions.openManagement(productKey);
|
|
918
|
+
},
|
|
919
|
+
|
|
920
|
+
confirmPriceChange(productKey) {
|
|
921
|
+
return subscriptions.confirmPriceChange(productKey);
|
|
922
|
+
},
|
|
923
|
+
|
|
924
|
+
async handleForeground(): Promise<void> {
|
|
925
|
+
// Rate-limited inside sync, so an app that calls this on every incidental
|
|
926
|
+
// foreground transition still produces at most one request per interval.
|
|
927
|
+
await engine.sync('foreground');
|
|
928
|
+
},
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Records a purchase refused because this environment may not move real
|
|
932
|
+
* money (FR-014). Written into status, not just logged, so a test can
|
|
933
|
+
* assert the block happened.
|
|
934
|
+
*/
|
|
935
|
+
recordBlockedOperation(productKey: ProductKey): void {
|
|
936
|
+
const blocked: BlockedOperation = {
|
|
937
|
+
productKey,
|
|
938
|
+
environment: config.environment,
|
|
939
|
+
at: now(),
|
|
940
|
+
};
|
|
941
|
+
setStatus({ blockedOperations: [...status.blockedOperations, blocked] });
|
|
942
|
+
emit({
|
|
943
|
+
type: 'environment_blocked',
|
|
944
|
+
productKey,
|
|
945
|
+
environment: config.environment,
|
|
946
|
+
});
|
|
947
|
+
},
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
return engine;
|
|
951
|
+
}
|