@chipmobilesdk/rn-payment 0.5.0 → 0.7.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 +172 -0
- package/README.md +47 -16
- package/lib/commonjs/adapter/createStoreProvider.js +110 -0
- package/lib/commonjs/adapter/createStoreProvider.js.map +1 -0
- package/lib/commonjs/adapter/iap-adapter.js +393 -0
- package/lib/commonjs/adapter/iap-adapter.js.map +1 -0
- package/lib/commonjs/adapter/iapShapes.js +169 -0
- package/lib/commonjs/adapter/iapShapes.js.map +1 -0
- package/lib/commonjs/adapter/port.js +6 -0
- package/lib/commonjs/adapter/port.js.map +1 -0
- package/lib/commonjs/config/types.js +2 -0
- package/lib/commonjs/config/types.js.map +1 -0
- package/lib/commonjs/config/validate.js +181 -0
- package/lib/commonjs/config/validate.js.map +1 -0
- package/lib/commonjs/core/mutex.js +31 -0
- package/lib/commonjs/core/mutex.js.map +1 -0
- package/lib/commonjs/core/retry.js +37 -0
- package/lib/commonjs/core/retry.js.map +1 -0
- package/lib/commonjs/core/timers.js +207 -0
- package/lib/commonjs/core/timers.js.map +1 -0
- package/lib/commonjs/diagnostics/errors.js +83 -0
- package/lib/commonjs/diagnostics/errors.js.map +1 -0
- package/lib/commonjs/diagnostics/events.js +126 -0
- package/lib/commonjs/diagnostics/events.js.map +1 -0
- package/lib/commonjs/diagnostics/messageKeys.js +70 -0
- package/lib/commonjs/diagnostics/messageKeys.js.map +1 -0
- package/lib/commonjs/engine/connection.js +189 -0
- package/lib/commonjs/engine/connection.js.map +1 -0
- package/lib/commonjs/engine/engine.js +279 -0
- package/lib/commonjs/engine/engine.js.map +1 -0
- package/lib/commonjs/engine/finalize.js +325 -0
- package/lib/commonjs/engine/finalize.js.map +1 -0
- package/lib/commonjs/engine/foreground.js +26 -0
- package/lib/commonjs/engine/foreground.js.map +1 -0
- package/lib/commonjs/engine/internals.js +31 -0
- package/lib/commonjs/engine/internals.js.map +1 -0
- package/lib/commonjs/engine/pending.js +102 -0
- package/lib/commonjs/engine/pending.js.map +1 -0
- package/lib/commonjs/engine/products.js +198 -0
- package/lib/commonjs/engine/products.js.map +1 -0
- package/lib/commonjs/engine/purchase.js +395 -0
- package/lib/commonjs/engine/purchase.js.map +1 -0
- package/lib/commonjs/engine/purchaseFailure.js +77 -0
- package/lib/commonjs/engine/purchaseFailure.js.map +1 -0
- package/lib/commonjs/engine/receiptValidation.js +90 -0
- package/lib/commonjs/engine/receiptValidation.js.map +1 -0
- package/lib/commonjs/engine/state.js +201 -0
- package/lib/commonjs/engine/state.js.map +1 -0
- package/lib/commonjs/engine/subscriptions.js +122 -0
- package/lib/commonjs/engine/subscriptions.js.map +1 -0
- package/lib/commonjs/engine/sync.js +163 -0
- package/lib/commonjs/engine/sync.js.map +1 -0
- package/lib/commonjs/entitlements/aggregate.js +212 -0
- package/lib/commonjs/entitlements/aggregate.js.map +1 -0
- package/lib/commonjs/entitlements/mapping.js +41 -0
- package/lib/commonjs/entitlements/mapping.js.map +1 -0
- package/lib/commonjs/entitlements/policy.js +110 -0
- package/lib/commonjs/entitlements/policy.js.map +1 -0
- package/lib/commonjs/index.js +266 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/persistence/mmkv-store.js +149 -0
- package/lib/commonjs/persistence/mmkv-store.js.map +1 -0
- package/lib/commonjs/persistence/snapshot.js +229 -0
- package/lib/commonjs/persistence/snapshot.js.map +1 -0
- package/lib/commonjs/react/PaymentProvider.js +96 -0
- package/lib/commonjs/react/PaymentProvider.js.map +1 -0
- package/lib/commonjs/react/hooks.js +202 -0
- package/lib/commonjs/react/hooks.js.map +1 -0
- package/lib/commonjs/testing/conformance.js +136 -0
- package/lib/commonjs/testing/conformance.js.map +1 -0
- package/lib/commonjs/testing/fakeStore.js +281 -0
- package/lib/commonjs/testing/fakeStore.js.map +1 -0
- package/lib/commonjs/testing/index.js +45 -0
- package/lib/commonjs/testing/index.js.map +1 -0
- package/lib/module/adapter/createStoreProvider.js +106 -0
- package/lib/module/adapter/createStoreProvider.js.map +1 -0
- package/lib/module/adapter/iap-adapter.js +383 -0
- package/lib/module/adapter/iap-adapter.js.map +1 -0
- package/lib/module/adapter/iapShapes.js +160 -0
- package/lib/module/adapter/iapShapes.js.map +1 -0
- package/lib/module/adapter/port.js +4 -0
- package/lib/module/adapter/port.js.map +1 -0
- package/lib/module/config/types.js +2 -0
- package/lib/module/config/types.js.map +1 -0
- package/lib/module/config/validate.js +175 -0
- package/lib/module/config/validate.js.map +1 -0
- package/lib/module/core/mutex.js +22 -0
- package/lib/module/core/mutex.js.map +1 -0
- package/lib/module/core/retry.js +33 -0
- package/lib/module/core/retry.js.map +1 -0
- package/lib/module/core/timers.js +198 -0
- package/lib/module/core/timers.js.map +1 -0
- package/lib/module/diagnostics/errors.js +76 -0
- package/lib/module/diagnostics/errors.js.map +1 -0
- package/lib/module/diagnostics/events.js +117 -0
- package/lib/module/diagnostics/events.js.map +1 -0
- package/lib/module/diagnostics/messageKeys.js +64 -0
- package/lib/module/diagnostics/messageKeys.js.map +1 -0
- package/lib/module/engine/connection.js +185 -0
- package/lib/module/engine/connection.js.map +1 -0
- package/lib/module/engine/engine.js +274 -0
- package/lib/module/engine/engine.js.map +1 -0
- package/lib/module/engine/finalize.js +322 -0
- package/lib/module/engine/finalize.js.map +1 -0
- package/lib/module/engine/foreground.js +21 -0
- package/lib/module/engine/foreground.js.map +1 -0
- package/lib/module/engine/internals.js +26 -0
- package/lib/module/engine/internals.js.map +1 -0
- package/lib/module/engine/pending.js +95 -0
- package/lib/module/engine/pending.js.map +1 -0
- package/lib/module/engine/products.js +195 -0
- package/lib/module/engine/products.js.map +1 -0
- package/lib/module/engine/purchase.js +390 -0
- package/lib/module/engine/purchase.js.map +1 -0
- package/lib/module/engine/purchaseFailure.js +72 -0
- package/lib/module/engine/purchaseFailure.js.map +1 -0
- package/lib/module/engine/receiptValidation.js +87 -0
- package/lib/module/engine/receiptValidation.js.map +1 -0
- package/lib/module/engine/state.js +196 -0
- package/lib/module/engine/state.js.map +1 -0
- package/lib/module/engine/subscriptions.js +119 -0
- package/lib/module/engine/subscriptions.js.map +1 -0
- package/lib/module/engine/sync.js +158 -0
- package/lib/module/engine/sync.js.map +1 -0
- package/lib/module/entitlements/aggregate.js +204 -0
- package/lib/module/entitlements/aggregate.js.map +1 -0
- package/lib/module/entitlements/mapping.js +35 -0
- package/lib/module/entitlements/mapping.js.map +1 -0
- package/lib/module/entitlements/policy.js +104 -0
- package/lib/module/entitlements/policy.js.map +1 -0
- package/lib/module/index.js +59 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/persistence/mmkv-store.js +144 -0
- package/lib/module/persistence/mmkv-store.js.map +1 -0
- package/lib/module/persistence/snapshot.js +221 -0
- package/lib/module/persistence/snapshot.js.map +1 -0
- package/lib/module/react/PaymentProvider.js +90 -0
- package/lib/module/react/PaymentProvider.js.map +1 -0
- package/lib/module/react/hooks.js +192 -0
- package/lib/module/react/hooks.js.map +1 -0
- package/lib/module/testing/conformance.js +130 -0
- package/lib/module/testing/conformance.js.map +1 -0
- package/lib/module/testing/fakeStore.js +276 -0
- package/lib/module/testing/fakeStore.js.map +1 -0
- package/lib/module/testing/index.js +13 -0
- package/lib/module/testing/index.js.map +1 -0
- package/lib/typescript/commonjs/adapter/createStoreProvider.d.ts +45 -0
- package/lib/typescript/commonjs/adapter/createStoreProvider.d.ts.map +1 -0
- package/lib/typescript/commonjs/adapter/iap-adapter.d.ts +80 -0
- package/lib/typescript/commonjs/adapter/iap-adapter.d.ts.map +1 -0
- package/lib/typescript/commonjs/adapter/iapShapes.d.ts +94 -0
- package/lib/typescript/commonjs/adapter/iapShapes.d.ts.map +1 -0
- package/lib/typescript/commonjs/adapter/port.d.ts +184 -0
- package/lib/typescript/commonjs/adapter/port.d.ts.map +1 -0
- package/lib/typescript/commonjs/config/types.d.ts +326 -0
- package/lib/typescript/commonjs/config/types.d.ts.map +1 -0
- package/lib/typescript/commonjs/config/validate.d.ts +63 -0
- package/lib/typescript/commonjs/config/validate.d.ts.map +1 -0
- package/lib/typescript/commonjs/core/mutex.d.ts +17 -0
- package/lib/typescript/commonjs/core/mutex.d.ts.map +1 -0
- package/lib/typescript/commonjs/core/retry.d.ts +19 -0
- package/lib/typescript/commonjs/core/retry.d.ts.map +1 -0
- package/lib/typescript/commonjs/core/timers.d.ts +142 -0
- package/lib/typescript/commonjs/core/timers.d.ts.map +1 -0
- package/lib/typescript/commonjs/diagnostics/errors.d.ts +55 -0
- package/lib/typescript/commonjs/diagnostics/errors.d.ts.map +1 -0
- package/lib/typescript/commonjs/diagnostics/events.d.ts +158 -0
- package/lib/typescript/commonjs/diagnostics/events.d.ts.map +1 -0
- package/lib/typescript/commonjs/diagnostics/messageKeys.d.ts +54 -0
- package/lib/typescript/commonjs/diagnostics/messageKeys.d.ts.map +1 -0
- package/lib/typescript/commonjs/engine/connection.d.ts +30 -0
- package/lib/typescript/commonjs/engine/connection.d.ts.map +1 -0
- package/lib/typescript/commonjs/engine/engine.d.ts +93 -0
- package/lib/typescript/commonjs/engine/engine.d.ts.map +1 -0
- package/lib/typescript/commonjs/engine/finalize.d.ts +53 -0
- package/lib/typescript/commonjs/engine/finalize.d.ts.map +1 -0
- package/lib/typescript/commonjs/engine/foreground.d.ts +15 -0
- package/lib/typescript/commonjs/engine/foreground.d.ts.map +1 -0
- package/lib/typescript/commonjs/engine/internals.d.ts +28 -0
- package/lib/typescript/commonjs/engine/internals.d.ts.map +1 -0
- package/lib/typescript/commonjs/engine/pending.d.ts +51 -0
- package/lib/typescript/commonjs/engine/pending.d.ts.map +1 -0
- package/lib/typescript/commonjs/engine/products.d.ts +82 -0
- package/lib/typescript/commonjs/engine/products.d.ts.map +1 -0
- package/lib/typescript/commonjs/engine/purchase.d.ts +61 -0
- package/lib/typescript/commonjs/engine/purchase.d.ts.map +1 -0
- package/lib/typescript/commonjs/engine/purchaseFailure.d.ts +15 -0
- package/lib/typescript/commonjs/engine/purchaseFailure.d.ts.map +1 -0
- package/lib/typescript/commonjs/engine/receiptValidation.d.ts +42 -0
- package/lib/typescript/commonjs/engine/receiptValidation.d.ts.map +1 -0
- package/lib/typescript/commonjs/engine/state.d.ts +46 -0
- package/lib/typescript/commonjs/engine/state.d.ts.map +1 -0
- package/lib/typescript/commonjs/engine/subscriptions.d.ts +61 -0
- package/lib/typescript/commonjs/engine/subscriptions.d.ts.map +1 -0
- package/lib/typescript/commonjs/engine/sync.d.ts +36 -0
- package/lib/typescript/commonjs/engine/sync.d.ts.map +1 -0
- package/lib/typescript/commonjs/entitlements/aggregate.d.ts +97 -0
- package/lib/typescript/commonjs/entitlements/aggregate.d.ts.map +1 -0
- package/lib/typescript/commonjs/entitlements/mapping.d.ts +27 -0
- package/lib/typescript/commonjs/entitlements/mapping.d.ts.map +1 -0
- package/lib/typescript/commonjs/entitlements/policy.d.ts +75 -0
- package/lib/typescript/commonjs/entitlements/policy.d.ts.map +1 -0
- package/lib/typescript/commonjs/index.d.ts +44 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/package.json +1 -0
- package/lib/typescript/commonjs/persistence/mmkv-store.d.ts +45 -0
- package/lib/typescript/commonjs/persistence/mmkv-store.d.ts.map +1 -0
- package/lib/typescript/commonjs/persistence/snapshot.d.ts +106 -0
- package/lib/typescript/commonjs/persistence/snapshot.d.ts.map +1 -0
- package/lib/typescript/commonjs/react/PaymentProvider.d.ts +57 -0
- package/lib/typescript/commonjs/react/PaymentProvider.d.ts.map +1 -0
- package/lib/typescript/commonjs/react/hooks.d.ts +80 -0
- package/lib/typescript/commonjs/react/hooks.d.ts.map +1 -0
- package/lib/typescript/commonjs/testing/conformance.d.ts +41 -0
- package/lib/typescript/commonjs/testing/conformance.d.ts.map +1 -0
- package/lib/typescript/commonjs/testing/fakeStore.d.ts +69 -0
- package/lib/typescript/commonjs/testing/fakeStore.d.ts.map +1 -0
- package/lib/typescript/commonjs/testing/index.d.ts +12 -0
- package/lib/typescript/commonjs/testing/index.d.ts.map +1 -0
- package/lib/typescript/module/adapter/createStoreProvider.d.ts +45 -0
- package/lib/typescript/module/adapter/createStoreProvider.d.ts.map +1 -0
- package/lib/typescript/module/adapter/iap-adapter.d.ts +80 -0
- package/lib/typescript/module/adapter/iap-adapter.d.ts.map +1 -0
- package/lib/typescript/module/adapter/iapShapes.d.ts +94 -0
- package/lib/typescript/module/adapter/iapShapes.d.ts.map +1 -0
- package/lib/typescript/module/adapter/port.d.ts +184 -0
- package/lib/typescript/module/adapter/port.d.ts.map +1 -0
- package/lib/typescript/module/config/types.d.ts +326 -0
- package/lib/typescript/module/config/types.d.ts.map +1 -0
- package/lib/typescript/module/config/validate.d.ts +63 -0
- package/lib/typescript/module/config/validate.d.ts.map +1 -0
- package/lib/typescript/module/core/mutex.d.ts +17 -0
- package/lib/typescript/module/core/mutex.d.ts.map +1 -0
- package/lib/typescript/module/core/retry.d.ts +19 -0
- package/lib/typescript/module/core/retry.d.ts.map +1 -0
- package/lib/typescript/module/core/timers.d.ts +142 -0
- package/lib/typescript/module/core/timers.d.ts.map +1 -0
- package/lib/typescript/module/diagnostics/errors.d.ts +55 -0
- package/lib/typescript/module/diagnostics/errors.d.ts.map +1 -0
- package/lib/typescript/module/diagnostics/events.d.ts +158 -0
- package/lib/typescript/module/diagnostics/events.d.ts.map +1 -0
- package/lib/typescript/module/diagnostics/messageKeys.d.ts +54 -0
- package/lib/typescript/module/diagnostics/messageKeys.d.ts.map +1 -0
- package/lib/typescript/module/engine/connection.d.ts +30 -0
- package/lib/typescript/module/engine/connection.d.ts.map +1 -0
- package/lib/typescript/module/engine/engine.d.ts +93 -0
- package/lib/typescript/module/engine/engine.d.ts.map +1 -0
- package/lib/typescript/module/engine/finalize.d.ts +53 -0
- package/lib/typescript/module/engine/finalize.d.ts.map +1 -0
- package/lib/typescript/module/engine/foreground.d.ts +15 -0
- package/lib/typescript/module/engine/foreground.d.ts.map +1 -0
- package/lib/typescript/module/engine/internals.d.ts +28 -0
- package/lib/typescript/module/engine/internals.d.ts.map +1 -0
- package/lib/typescript/module/engine/pending.d.ts +51 -0
- package/lib/typescript/module/engine/pending.d.ts.map +1 -0
- package/lib/typescript/module/engine/products.d.ts +82 -0
- package/lib/typescript/module/engine/products.d.ts.map +1 -0
- package/lib/typescript/module/engine/purchase.d.ts +61 -0
- package/lib/typescript/module/engine/purchase.d.ts.map +1 -0
- package/lib/typescript/module/engine/purchaseFailure.d.ts +15 -0
- package/lib/typescript/module/engine/purchaseFailure.d.ts.map +1 -0
- package/lib/typescript/module/engine/receiptValidation.d.ts +42 -0
- package/lib/typescript/module/engine/receiptValidation.d.ts.map +1 -0
- package/lib/typescript/module/engine/state.d.ts +46 -0
- package/lib/typescript/module/engine/state.d.ts.map +1 -0
- package/lib/typescript/module/engine/subscriptions.d.ts +61 -0
- package/lib/typescript/module/engine/subscriptions.d.ts.map +1 -0
- package/lib/typescript/module/engine/sync.d.ts +36 -0
- package/lib/typescript/module/engine/sync.d.ts.map +1 -0
- package/lib/typescript/module/entitlements/aggregate.d.ts +97 -0
- package/lib/typescript/module/entitlements/aggregate.d.ts.map +1 -0
- package/lib/typescript/module/entitlements/mapping.d.ts +27 -0
- package/lib/typescript/module/entitlements/mapping.d.ts.map +1 -0
- package/lib/typescript/module/entitlements/policy.d.ts +75 -0
- package/lib/typescript/module/entitlements/policy.d.ts.map +1 -0
- package/lib/typescript/module/index.d.ts +44 -0
- package/lib/typescript/module/index.d.ts.map +1 -0
- package/lib/typescript/module/package.json +1 -0
- package/lib/typescript/module/persistence/mmkv-store.d.ts +45 -0
- package/lib/typescript/module/persistence/mmkv-store.d.ts.map +1 -0
- package/lib/typescript/module/persistence/snapshot.d.ts +106 -0
- package/lib/typescript/module/persistence/snapshot.d.ts.map +1 -0
- package/lib/typescript/module/react/PaymentProvider.d.ts +57 -0
- package/lib/typescript/module/react/PaymentProvider.d.ts.map +1 -0
- package/lib/typescript/module/react/hooks.d.ts +80 -0
- package/lib/typescript/module/react/hooks.d.ts.map +1 -0
- package/lib/typescript/module/testing/conformance.d.ts +41 -0
- package/lib/typescript/module/testing/conformance.d.ts.map +1 -0
- package/lib/typescript/module/testing/fakeStore.d.ts +69 -0
- package/lib/typescript/module/testing/fakeStore.d.ts.map +1 -0
- package/lib/typescript/module/testing/index.d.ts +12 -0
- package/lib/typescript/module/testing/index.d.ts.map +1 -0
- package/package.json +49 -13
- package/skills/sdk-rn-payment/SKILL.md +4 -4
- package/src/adapter/iap-adapter.ts +12 -216
- package/src/adapter/iapShapes.ts +232 -0
- package/src/config/types.ts +8 -0
- package/src/config/validate.ts +19 -4
- package/src/core/retry.ts +1 -58
- package/src/diagnostics/events.ts +1 -1
- package/src/engine/connection.ts +194 -0
- package/src/engine/engine.ts +140 -700
- package/src/engine/internals.ts +40 -0
- package/src/engine/products.ts +39 -17
- package/src/engine/purchase.ts +22 -130
- package/src/engine/purchaseFailure.ts +48 -0
- package/src/engine/receiptValidation.ts +101 -0
- package/src/engine/state.ts +253 -0
- package/src/engine/sync.ts +161 -0
- package/src/entitlements/aggregate.ts +3 -5
- package/src/entitlements/mapping.ts +0 -22
- package/src/persistence/mmkv-store.ts +10 -3
- package/src/persistence/snapshot.ts +102 -27
- package/src/react/PaymentProvider.tsx +73 -24
- package/src/react/hooks.ts +13 -3
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engine's state and the one way it changes (PAY-10).
|
|
3
|
+
*
|
|
4
|
+
* Status, the persisted snapshot, entitlements and pending purchases live here
|
|
5
|
+
* with the listeners that hear about them. Every change goes through `commit`
|
|
6
|
+
* (the snapshot) or `setStatus`, so diffing and emission are written once:
|
|
7
|
+
* an entitlement is granted or revoked exactly once however many sources it has
|
|
8
|
+
* (FR-079), a renewal that changes nothing emits nothing (FR-068), and an
|
|
9
|
+
* unchanged map is the same object (PERF-03).
|
|
10
|
+
*/
|
|
11
|
+
import { createListenerSet, type ListenerSet } from '@chipmobilesdk/rn-core';
|
|
12
|
+
|
|
13
|
+
import type { ProviderPort } from '../adapter/port';
|
|
14
|
+
import type { ResolvedConfig } from '../config/validate';
|
|
15
|
+
import type { PaymentConfig, PaymentStatus, PendingPurchasesState } from '../config/types';
|
|
16
|
+
import { redact } from '../diagnostics/errors';
|
|
17
|
+
import {
|
|
18
|
+
createEmitter,
|
|
19
|
+
type PaymentEmitter,
|
|
20
|
+
type PaymentEvent,
|
|
21
|
+
type PaymentEventInput,
|
|
22
|
+
} from '../diagnostics/events';
|
|
23
|
+
import {
|
|
24
|
+
aggregate,
|
|
25
|
+
diffEntitlements,
|
|
26
|
+
initialEntitlements,
|
|
27
|
+
shareUnchanged,
|
|
28
|
+
type EntitlementMap,
|
|
29
|
+
} from '../entitlements/aggregate';
|
|
30
|
+
import type { SnapshotStore } from '../persistence/mmkv-store';
|
|
31
|
+
import { emptySnapshot, type PersistedSnapshot } from '../persistence/snapshot';
|
|
32
|
+
import { derivePending, pendingTransitions, samePending, UNKNOWN_PENDING } from './pending';
|
|
33
|
+
|
|
34
|
+
type RevokeReason = 'expired' | 'revoked_by_store' | 'validation_rejected' | 'consumed';
|
|
35
|
+
|
|
36
|
+
export interface EngineState {
|
|
37
|
+
readonly config: PaymentConfig;
|
|
38
|
+
readonly resolved: ResolvedConfig;
|
|
39
|
+
readonly provider: ProviderPort;
|
|
40
|
+
readonly store: SnapshotStore;
|
|
41
|
+
readonly now: () => number;
|
|
42
|
+
readonly emitter: PaymentEmitter;
|
|
43
|
+
readonly statusListeners: ListenerSet<[PaymentStatus]>;
|
|
44
|
+
readonly entitlementListeners: ListenerSet<[EntitlementMap]>;
|
|
45
|
+
readonly pendingListeners: ListenerSet<[PendingPurchasesState]>;
|
|
46
|
+
|
|
47
|
+
disposed: boolean;
|
|
48
|
+
/** Whether the snapshot comes from a sync that succeeded; until then entitlements are `unknown`. */
|
|
49
|
+
hasSynced: boolean;
|
|
50
|
+
snapshot: PersistedSnapshot;
|
|
51
|
+
entitlements: EntitlementMap;
|
|
52
|
+
status: PaymentStatus;
|
|
53
|
+
pending: PendingPurchasesState;
|
|
54
|
+
|
|
55
|
+
emit(event: PaymentEventInput): void;
|
|
56
|
+
setStatus(next: Partial<PaymentStatus>): void;
|
|
57
|
+
/** Writes the snapshot, reports pending purchases that ended, and recomputes. */
|
|
58
|
+
commit(next: PersistedSnapshot): void;
|
|
59
|
+
/** Loads the persisted snapshot at startup (FR-053). */
|
|
60
|
+
hydrate(): void;
|
|
61
|
+
/** Forgets everything local, back to `unknown` (FR-057). */
|
|
62
|
+
forget(): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createEngineState(
|
|
66
|
+
config: PaymentConfig,
|
|
67
|
+
resolved: ResolvedConfig,
|
|
68
|
+
provider: ProviderPort,
|
|
69
|
+
store: SnapshotStore,
|
|
70
|
+
now: () => number,
|
|
71
|
+
): EngineState {
|
|
72
|
+
// A listener that throws is a consumer bug, so it goes to the app's own logger
|
|
73
|
+
// rather than back into the event stream (FR-080, FR-081).
|
|
74
|
+
const emitter = createEmitter((error, event) => {
|
|
75
|
+
config.logger?.error?.('payment event listener threw', {
|
|
76
|
+
eventType: event.type,
|
|
77
|
+
reason: redact(error),
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
const listenerOptions = {
|
|
81
|
+
onListenerError: (error: unknown) =>
|
|
82
|
+
config.logger?.error?.('payment listener threw', { reason: redact(error) }),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Why an entitlement that was held is held no more, from what its sources left
|
|
87
|
+
* behind (PAY-12): a backend rejection, a consumed item, an expired
|
|
88
|
+
* subscription — or, when the store simply stopped reporting it, a refund.
|
|
89
|
+
*/
|
|
90
|
+
function revokeReason(sources: readonly string[]): RevokeReason {
|
|
91
|
+
const records = state.snapshot.purchases.filter(record => sources.includes(record.productKey));
|
|
92
|
+
if (records.some(record => record.validationState === 'rejected')) {
|
|
93
|
+
return 'validation_rejected';
|
|
94
|
+
}
|
|
95
|
+
if (records.some(record => record.state === 'consumed')) {
|
|
96
|
+
return 'consumed';
|
|
97
|
+
}
|
|
98
|
+
const at = now();
|
|
99
|
+
if (records.some(record => record.expiresAt !== undefined && record.expiresAt <= at)) {
|
|
100
|
+
return 'expired';
|
|
101
|
+
}
|
|
102
|
+
return 'revoked_by_store';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function publishPending(next: PendingPurchasesState): void {
|
|
106
|
+
// Diffed: a sync that changes nothing is the common case, and an app
|
|
107
|
+
// rendering "payment pending" has no use for a re-render that says the same.
|
|
108
|
+
if (samePending(state.pending, next)) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
state.pending = next;
|
|
112
|
+
state.pendingListeners.notify(next);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function recompute(): void {
|
|
116
|
+
if (state.disposed) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const previous = state.entitlements;
|
|
120
|
+
state.entitlements = shareUnchanged(
|
|
121
|
+
previous,
|
|
122
|
+
aggregate({
|
|
123
|
+
config: resolved,
|
|
124
|
+
snapshot: state.snapshot,
|
|
125
|
+
hasSynced: state.hasSynced,
|
|
126
|
+
reportsGraceAndHoldDistinctly: provider.capabilities().reportsGraceAndHoldDistinctly,
|
|
127
|
+
now: now(),
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
for (const change of diffEntitlements(previous, state.entitlements)) {
|
|
131
|
+
if (change.next === 'entitled') {
|
|
132
|
+
state.emit({
|
|
133
|
+
type: 'entitlement_granted',
|
|
134
|
+
entitlementKey: change.key,
|
|
135
|
+
sources: change.sources,
|
|
136
|
+
});
|
|
137
|
+
} else if (change.previous === 'entitled') {
|
|
138
|
+
state.emit({
|
|
139
|
+
type: 'entitlement_revoked',
|
|
140
|
+
entitlementKey: change.key,
|
|
141
|
+
reason: revokeReason(previous[change.key]?.sources ?? []),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (state.entitlements !== previous) {
|
|
146
|
+
state.entitlementListeners.notify(state.entitlements);
|
|
147
|
+
}
|
|
148
|
+
publishPending(
|
|
149
|
+
derivePending({
|
|
150
|
+
config: resolved,
|
|
151
|
+
snapshot: state.snapshot,
|
|
152
|
+
hasSynced: state.hasSynced,
|
|
153
|
+
now: now(),
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const state: EngineState = {
|
|
159
|
+
config,
|
|
160
|
+
resolved,
|
|
161
|
+
provider,
|
|
162
|
+
store,
|
|
163
|
+
now,
|
|
164
|
+
emitter,
|
|
165
|
+
statusListeners: createListenerSet<[PaymentStatus]>(listenerOptions),
|
|
166
|
+
entitlementListeners: createListenerSet<[EntitlementMap]>(listenerOptions),
|
|
167
|
+
pendingListeners: createListenerSet<[PendingPurchasesState]>(listenerOptions),
|
|
168
|
+
|
|
169
|
+
disposed: false,
|
|
170
|
+
hasSynced: false,
|
|
171
|
+
snapshot: emptySnapshot(config.scopeToken),
|
|
172
|
+
entitlements: initialEntitlements(resolved),
|
|
173
|
+
status: { kind: resolved.enabled ? 'not_started' : 'disabled', blockedOperations: [] },
|
|
174
|
+
pending: UNKNOWN_PENDING,
|
|
175
|
+
|
|
176
|
+
emit(event) {
|
|
177
|
+
emitter.emit({ ...event, timestamp: event.timestamp ?? now() } as PaymentEvent);
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
setStatus(next) {
|
|
181
|
+
if (state.disposed) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
state.status = { ...state.status, ...next };
|
|
185
|
+
state.emit({
|
|
186
|
+
type: 'initialization',
|
|
187
|
+
status: state.status.kind,
|
|
188
|
+
unavailableReason: state.status.unavailableReason,
|
|
189
|
+
retryable: state.status.retryable,
|
|
190
|
+
});
|
|
191
|
+
state.statusListeners.notify(state.status);
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
commit(next) {
|
|
195
|
+
// Read before the swap: a transition is "pending before, not pending now",
|
|
196
|
+
// and only the commit sees both sides (FR-104).
|
|
197
|
+
const ended = pendingTransitions(state.snapshot, next, resolved, transactionId =>
|
|
198
|
+
provider.pendingEndReason?.(transactionId),
|
|
199
|
+
);
|
|
200
|
+
state.snapshot = next;
|
|
201
|
+
if (store.save(next) === false) {
|
|
202
|
+
// The write-intent-first guarantee (FR-042) did not hold for this write.
|
|
203
|
+
// Said out loud: the app learns its finalize queue may not survive a kill.
|
|
204
|
+
state.emit({
|
|
205
|
+
type: 'internal_error',
|
|
206
|
+
code: 'PAYMENT_STORAGE_UNAVAILABLE',
|
|
207
|
+
operation: 'persist',
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
// Before `recompute`, so `settled` precedes the `entitlement_granted` it
|
|
211
|
+
// causes (FR-102).
|
|
212
|
+
for (const transition of ended) {
|
|
213
|
+
state.emit({
|
|
214
|
+
type: 'purchase_pending_resolved',
|
|
215
|
+
productKey: transition.productKey,
|
|
216
|
+
outcome: transition.outcome,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
recompute();
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
hydrate() {
|
|
223
|
+
state.snapshot = store.load(config.scopeToken);
|
|
224
|
+
// A record with `lastSyncedAt` was written by a sync that succeeded, so it
|
|
225
|
+
// is a basis for asserting entitlement — and the only one a build with
|
|
226
|
+
// selling disabled will ever have (FR-012, FR-053).
|
|
227
|
+
if (state.snapshot.lastSyncedAt !== undefined) {
|
|
228
|
+
state.hasSynced = true;
|
|
229
|
+
}
|
|
230
|
+
if (!store.isDurable()) {
|
|
231
|
+
state.emit({
|
|
232
|
+
type: 'config_warning',
|
|
233
|
+
detail: 'Local storage unavailable; entitlements will not survive restart',
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
recompute();
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
forget() {
|
|
240
|
+
store.clearAll();
|
|
241
|
+
state.snapshot = emptySnapshot(config.scopeToken);
|
|
242
|
+
state.hasSynced = false;
|
|
243
|
+
// Back to `unknown`, not `not_entitled`: after a logout there is no basis
|
|
244
|
+
// for either claim until the next sync.
|
|
245
|
+
state.entitlements = initialEntitlements(resolved);
|
|
246
|
+
state.entitlementListeners.notify(state.entitlements);
|
|
247
|
+
// No `purchase_pending_resolved`: the records were discarded locally, not
|
|
248
|
+
// ended at the store.
|
|
249
|
+
publishPending(UNKNOWN_PENDING);
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
return state;
|
|
253
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Syncing with the store, and the one translation from a store purchase to a
|
|
3
|
+
* persisted record (PAY-10).
|
|
4
|
+
*
|
|
5
|
+
* The purchase flow and sync both build records here, so they cannot disagree
|
|
6
|
+
* about what a transaction still owes: a disagreement is how a paid purchase
|
|
7
|
+
* goes unacknowledged and is refunded three days later.
|
|
8
|
+
*/
|
|
9
|
+
import type { ProviderPurchase } from '../adapter/port';
|
|
10
|
+
import type { ProductKey } from '../config/types';
|
|
11
|
+
import type { ResolvedConfig } from '../config/validate';
|
|
12
|
+
import { PaymentError } from '../diagnostics/errors';
|
|
13
|
+
import type { SyncTrigger } from '../diagnostics/events';
|
|
14
|
+
import { productKeyForStoreId } from '../entitlements/mapping';
|
|
15
|
+
import { createRateLimiter, createSingleFlight, withTimeout } from '../core/mutex';
|
|
16
|
+
import { resolveFinalizeState, type PersistedPurchase } from '../persistence/snapshot';
|
|
17
|
+
import type { EngineState } from './state';
|
|
18
|
+
|
|
19
|
+
export type SyncOutcome =
|
|
20
|
+
{ ok: true; restoredCount: number; revokedCount: number } | { ok: false; error: PaymentError };
|
|
21
|
+
|
|
22
|
+
/** The persisted record for one store purchase, keeping what we already knew about it. */
|
|
23
|
+
export function toPersistedRecord(
|
|
24
|
+
resolved: ResolvedConfig,
|
|
25
|
+
purchase: ProviderPurchase,
|
|
26
|
+
productKey: ProductKey,
|
|
27
|
+
existing: PersistedPurchase | undefined,
|
|
28
|
+
): PersistedPurchase {
|
|
29
|
+
return {
|
|
30
|
+
transactionId: purchase.transactionId,
|
|
31
|
+
productKey,
|
|
32
|
+
productType: resolved.entries.get(productKey)?.productType ?? 'one_time_permanent',
|
|
33
|
+
token: purchase.token,
|
|
34
|
+
state: purchase.state === 'pending' ? 'pending' : 'purchased',
|
|
35
|
+
purchasedAt: purchase.purchasedAt,
|
|
36
|
+
// Finalize progress survives: a sync must never reset a queued
|
|
37
|
+
// acknowledgement and lose the paid transaction.
|
|
38
|
+
finalizeState: resolveFinalizeState(purchase, existing),
|
|
39
|
+
attempts: existing?.attempts ?? 0,
|
|
40
|
+
validationState: existing?.validationState ?? 'not_required',
|
|
41
|
+
expiresAt: purchase.expiresAt,
|
|
42
|
+
willAutoRenew: purchase.willAutoRenew,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Store purchases as records, dropping products this catalog does not know. */
|
|
47
|
+
function toPersisted(
|
|
48
|
+
resolved: ResolvedConfig,
|
|
49
|
+
purchases: readonly ProviderPurchase[],
|
|
50
|
+
known: readonly PersistedPurchase[],
|
|
51
|
+
): PersistedPurchase[] {
|
|
52
|
+
const records: PersistedPurchase[] = [];
|
|
53
|
+
for (const purchase of purchases) {
|
|
54
|
+
// Legitimately possible: a user owns something from a build whose catalog
|
|
55
|
+
// has since changed. Ignored rather than treated as impossible.
|
|
56
|
+
const productKey = productKeyForStoreId(resolved, purchase.storeProductId);
|
|
57
|
+
if (!productKey || !resolved.entries.has(productKey)) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const existing = known.find(p => p.transactionId === purchase.transactionId);
|
|
61
|
+
if (purchase.state === 'unspecified') {
|
|
62
|
+
// No basis for granting, finalizing or ending anything: a new transaction
|
|
63
|
+
// waits until the store says what it is, a known one keeps what we knew.
|
|
64
|
+
if (existing) {
|
|
65
|
+
records.push(existing);
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
records.push(toPersistedRecord(resolved, purchase, productKey, existing));
|
|
70
|
+
}
|
|
71
|
+
return records;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function purchasedIds(records: readonly PersistedPurchase[]): Set<string> {
|
|
75
|
+
return new Set(records.filter(p => p.state === 'purchased').map(p => p.transactionId));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface Sync {
|
|
79
|
+
sync(trigger?: SyncTrigger): Promise<SyncOutcome>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `settle` runs after every successful sync, outside the sync flight: the finalize
|
|
84
|
+
* drain and backend revalidation (FR-039, FR-042, FR-072a). Inside the flight, one
|
|
85
|
+
* store call or hook that never answered held it, and every later sync joined
|
|
86
|
+
* that stuck promise for the rest of the session.
|
|
87
|
+
*/
|
|
88
|
+
export function createSync(state: EngineState, settle: () => Promise<void>): Sync {
|
|
89
|
+
const { resolved, provider, now } = state;
|
|
90
|
+
const syncFlight = createSingleFlight<SyncOutcome>();
|
|
91
|
+
const settleFlight = createSingleFlight<void>();
|
|
92
|
+
const foregroundLimiter = createRateLimiter(resolved.foregroundSyncMinIntervalMs, now);
|
|
93
|
+
|
|
94
|
+
async function querySnapshot(trigger: SyncTrigger): Promise<SyncOutcome> {
|
|
95
|
+
try {
|
|
96
|
+
const owned = await withTimeout(
|
|
97
|
+
provider.queryOwned(),
|
|
98
|
+
resolved.timeouts.syncMs,
|
|
99
|
+
() =>
|
|
100
|
+
new PaymentError({
|
|
101
|
+
code: 'PAYMENT_TIMEOUT',
|
|
102
|
+
message: 'Syncing entitlements timed out',
|
|
103
|
+
operation: 'sync',
|
|
104
|
+
retryable: true,
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
const before = purchasedIds(state.snapshot.purchases);
|
|
108
|
+
const records = toPersisted(resolved, owned, state.snapshot.purchases);
|
|
109
|
+
const after = purchasedIds(records);
|
|
110
|
+
// A transaction the store no longer reports is revoked — a refund. The
|
|
111
|
+
// cache must not resurrect it (FR-064, FR-055).
|
|
112
|
+
const revokedCount = [...before].filter(id => !after.has(id)).length;
|
|
113
|
+
const restoredCount = [...after].filter(id => !before.has(id)).length;
|
|
114
|
+
|
|
115
|
+
state.hasSynced = true;
|
|
116
|
+
state.commit({ ...state.snapshot, purchases: records, lastSyncedAt: now() });
|
|
117
|
+
state.emit({ type: 'sync_completed', restoredCount, revokedCount, trigger });
|
|
118
|
+
return { ok: true, restoredCount, revokedCount };
|
|
119
|
+
} catch (error) {
|
|
120
|
+
const paymentError =
|
|
121
|
+
error instanceof PaymentError
|
|
122
|
+
? error
|
|
123
|
+
: new PaymentError({
|
|
124
|
+
code: 'PAYMENT_STORE_ERROR',
|
|
125
|
+
message: 'Sync failed',
|
|
126
|
+
operation: 'sync',
|
|
127
|
+
retryable: true,
|
|
128
|
+
});
|
|
129
|
+
// A failed sync leaves the previous snapshot in place, so offline
|
|
130
|
+
// entitlements survive rather than being wiped (FR-053).
|
|
131
|
+
state.emit({ type: 'internal_error', code: paymentError.code, operation: 'sync' });
|
|
132
|
+
return { ok: false, error: paymentError };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
async sync(trigger = 'manual') {
|
|
138
|
+
if (state.disposed || !resolved.enabled) {
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
error: new PaymentError({
|
|
142
|
+
code: 'PAYMENT_DISABLED',
|
|
143
|
+
message: 'Payments are disabled',
|
|
144
|
+
operation: 'sync',
|
|
145
|
+
}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
// Rejected rather than deferred: the next foreground event tries again,
|
|
149
|
+
// whereas queueing would fire a burst once the interval elapsed (PERF-002).
|
|
150
|
+
if (trigger === 'foreground' && !foregroundLimiter.tryAcquire()) {
|
|
151
|
+
return { ok: true, restoredCount: 0, revokedCount: 0 };
|
|
152
|
+
}
|
|
153
|
+
// Concurrent callers join one flight: one store request (FR-063).
|
|
154
|
+
const outcome = await syncFlight.run(() => querySnapshot(trigger));
|
|
155
|
+
if (outcome.ok) {
|
|
156
|
+
await settleFlight.run(settle);
|
|
157
|
+
}
|
|
158
|
+
return outcome;
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -56,11 +56,9 @@ export interface AggregateInput {
|
|
|
56
56
|
|
|
57
57
|
/** A purchase that currently confers its entitlements. */
|
|
58
58
|
function isValidSource(purchase: PersistedPurchase, input: AggregateInput): boolean {
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
if (purchase.state === 'revoked' || purchase.state === 'consumed') {
|
|
59
|
+
// Only a purchase the store confirmed grants. Pending money is not money, and a
|
|
60
|
+
// state this version does not know is no basis for a grant either (PAY-13).
|
|
61
|
+
if (purchase.state !== 'purchased') {
|
|
64
62
|
return false;
|
|
65
63
|
}
|
|
66
64
|
// A backend that said no is final (FR-071). A queued re-validation still
|
|
@@ -21,14 +21,6 @@ export function entitlementsFor(
|
|
|
21
21
|
return config.grants.get(productKey) ?? [];
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
/** Products that could grant an entitlement, whether or not any is currently valid. */
|
|
25
|
-
export function sourcesFor(
|
|
26
|
-
config: ResolvedConfig,
|
|
27
|
-
entitlementKey: EntitlementKey,
|
|
28
|
-
): readonly ProductKey[] {
|
|
29
|
-
return config.sources.get(entitlementKey) ?? [];
|
|
30
|
-
}
|
|
31
|
-
|
|
32
24
|
/**
|
|
33
25
|
* Translates a store product id back to the app's own product key.
|
|
34
26
|
*
|
|
@@ -51,17 +43,3 @@ export function storeIdForProductKey(
|
|
|
51
43
|
): string | undefined {
|
|
52
44
|
return config.entries.get(productKey)?.storeProductIds[config.platform];
|
|
53
45
|
}
|
|
54
|
-
|
|
55
|
-
/** Which entitlements a set of granting products covers, de-duplicated. */
|
|
56
|
-
export function entitlementsCoveredBy(
|
|
57
|
-
config: ResolvedConfig,
|
|
58
|
-
productKeys: Iterable<ProductKey>,
|
|
59
|
-
): Set<EntitlementKey> {
|
|
60
|
-
const covered = new Set<EntitlementKey>();
|
|
61
|
-
for (const productKey of productKeys) {
|
|
62
|
-
for (const entitlement of entitlementsFor(config, productKey)) {
|
|
63
|
-
covered.add(entitlement);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return covered;
|
|
67
|
-
}
|
|
@@ -32,7 +32,11 @@ const mmkv = lazyPeer('react-native-mmkv', () => {
|
|
|
32
32
|
|
|
33
33
|
export interface SnapshotStore {
|
|
34
34
|
load(scopeToken?: string): PersistedSnapshot;
|
|
35
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Writes the snapshot. Returns false when the write failed, so the engine can
|
|
37
|
+
* say so; a store returning nothing is taken to have succeeded.
|
|
38
|
+
*/
|
|
39
|
+
save(snapshot: PersistedSnapshot): boolean | void;
|
|
36
40
|
/** Clears every scope — for logout and data-deletion requests (FR-057). */
|
|
37
41
|
clearAll(): void;
|
|
38
42
|
/** Whether writes actually persist. False means the memory fallback is active. */
|
|
@@ -117,12 +121,15 @@ export function createSnapshotStore(injected?: {
|
|
|
117
121
|
return outcome.snapshot;
|
|
118
122
|
},
|
|
119
123
|
|
|
120
|
-
save(snapshot: PersistedSnapshot):
|
|
124
|
+
save(snapshot: PersistedSnapshot): boolean {
|
|
121
125
|
try {
|
|
122
126
|
backing.set(scopeKey(snapshot.scopeToken), JSON.stringify(snapshot));
|
|
127
|
+
return true;
|
|
123
128
|
} catch {
|
|
124
129
|
// A failed write must never take down a purchase flow. The in-memory
|
|
125
|
-
// state stays correct for this session and the next sync re-derives it
|
|
130
|
+
// state stays correct for this session and the next sync re-derives it;
|
|
131
|
+
// the engine reports it (PAY-13).
|
|
132
|
+
return false;
|
|
126
133
|
}
|
|
127
134
|
},
|
|
128
135
|
|
|
@@ -9,12 +9,16 @@
|
|
|
9
9
|
* identity, raw receipts, verification payloads, and backend tokens.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import type {
|
|
12
|
+
import type { ProductKey, ProductType } from '../config/types';
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* 2 since 0.6.0: the never-written `entitlements` map and `finalized` state are
|
|
16
|
+
* gone (PAY-12). Entitlements are always derived from purchases, never stored.
|
|
17
|
+
*/
|
|
18
|
+
export const SNAPSHOT_SCHEMA_VERSION = 2;
|
|
15
19
|
|
|
16
20
|
/** Where a transaction stands with the store. */
|
|
17
|
-
export type PersistedPurchaseState = 'pending' | 'purchased' | '
|
|
21
|
+
export type PersistedPurchaseState = 'pending' | 'purchased' | 'consumed' | 'revoked';
|
|
18
22
|
|
|
19
23
|
/**
|
|
20
24
|
* Where a transaction stands in the finalization queue.
|
|
@@ -44,18 +48,10 @@ export interface PersistedPurchase {
|
|
|
44
48
|
willAutoRenew?: boolean;
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
export interface PersistedEntitlement {
|
|
48
|
-
sources: ProductKey[];
|
|
49
|
-
expiresAt?: number;
|
|
50
|
-
willAutoRenew?: boolean;
|
|
51
|
-
isCancelledButValid?: boolean;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
51
|
export interface PersistedSnapshot {
|
|
55
52
|
schemaVersion: number;
|
|
56
53
|
/** Opaque partition key. Stored only to scope this record (FR-057a). */
|
|
57
54
|
scopeToken?: string;
|
|
58
|
-
entitlements: Record<EntitlementKey, PersistedEntitlement>;
|
|
59
55
|
purchases: PersistedPurchase[];
|
|
60
56
|
lastSyncedAt?: number;
|
|
61
57
|
}
|
|
@@ -64,7 +60,6 @@ export function emptySnapshot(scopeToken?: string): PersistedSnapshot {
|
|
|
64
60
|
return {
|
|
65
61
|
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
|
|
66
62
|
scopeToken,
|
|
67
|
-
entitlements: {},
|
|
68
63
|
purchases: [],
|
|
69
64
|
lastSyncedAt: undefined,
|
|
70
65
|
};
|
|
@@ -83,14 +78,30 @@ export interface SnapshotMigration {
|
|
|
83
78
|
migrate(input: Record<string, unknown>): Record<string, unknown>;
|
|
84
79
|
}
|
|
85
80
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
81
|
+
/** Registered migrations, one hop each. */
|
|
82
|
+
export const SNAPSHOT_MIGRATIONS: readonly SnapshotMigration[] = [
|
|
83
|
+
{
|
|
84
|
+
// v1 stored an `entitlements` map nothing ever wrote to, and a `finalized`
|
|
85
|
+
// purchase state nothing ever set (PAY-12). Both go; a `finalized` record,
|
|
86
|
+
// should one exist, is a purchased one whose finalize progress is kept.
|
|
87
|
+
from: 1,
|
|
88
|
+
to: 2,
|
|
89
|
+
migrate(input) {
|
|
90
|
+
const rest = { ...input };
|
|
91
|
+
delete rest.entitlements;
|
|
92
|
+
const purchases = Array.isArray(rest.purchases)
|
|
93
|
+
? rest.purchases.map(record =>
|
|
94
|
+
typeof record === 'object' &&
|
|
95
|
+
record !== null &&
|
|
96
|
+
(record as { state?: unknown }).state === 'finalized'
|
|
97
|
+
? { ...(record as object), state: 'purchased' }
|
|
98
|
+
: record,
|
|
99
|
+
)
|
|
100
|
+
: rest.purchases;
|
|
101
|
+
return { ...rest, schemaVersion: 2, purchases };
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
];
|
|
94
105
|
|
|
95
106
|
export type MigrationOutcome =
|
|
96
107
|
| { ok: true; snapshot: PersistedSnapshot; migrated: boolean }
|
|
@@ -146,12 +157,7 @@ export function migrateSnapshot(raw: unknown): MigrationOutcome {
|
|
|
146
157
|
* truncated file.
|
|
147
158
|
*/
|
|
148
159
|
function coerceSnapshot(input: Record<string, unknown>): PersistedSnapshot | null {
|
|
149
|
-
const entitlements = input.entitlements;
|
|
150
160
|
const purchases = input.purchases;
|
|
151
|
-
|
|
152
|
-
if (typeof entitlements !== 'object' || entitlements === null) {
|
|
153
|
-
return null;
|
|
154
|
-
}
|
|
155
161
|
if (!Array.isArray(purchases)) {
|
|
156
162
|
return null;
|
|
157
163
|
}
|
|
@@ -162,12 +168,81 @@ function coerceSnapshot(input: Record<string, unknown>): PersistedSnapshot | nul
|
|
|
162
168
|
return {
|
|
163
169
|
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
|
|
164
170
|
scopeToken,
|
|
165
|
-
|
|
166
|
-
|
|
171
|
+
// Record by record: one damaged entry is dropped, not trusted and not
|
|
172
|
+
// allowed to discard the rest (PAY-13). A dropped record comes back with the
|
|
173
|
+
// next sync, if the store still reports it.
|
|
174
|
+
purchases: purchases.flatMap(record => {
|
|
175
|
+
const valid = coercePurchase(record);
|
|
176
|
+
return valid ? [valid] : [];
|
|
177
|
+
}),
|
|
167
178
|
lastSyncedAt,
|
|
168
179
|
};
|
|
169
180
|
}
|
|
170
181
|
|
|
182
|
+
const PURCHASE_STATES: ReadonlySet<string> = new Set([
|
|
183
|
+
'pending',
|
|
184
|
+
'purchased',
|
|
185
|
+
'consumed',
|
|
186
|
+
'revoked',
|
|
187
|
+
]);
|
|
188
|
+
const FINALIZE_STATES: ReadonlySet<string> = new Set([
|
|
189
|
+
'not_required',
|
|
190
|
+
'queued',
|
|
191
|
+
'in_flight',
|
|
192
|
+
'done',
|
|
193
|
+
'failed',
|
|
194
|
+
]);
|
|
195
|
+
const VALIDATION_STATES: ReadonlySet<string> = new Set([
|
|
196
|
+
'not_required',
|
|
197
|
+
'approved',
|
|
198
|
+
'rejected',
|
|
199
|
+
'queued_for_revalidation',
|
|
200
|
+
]);
|
|
201
|
+
const PRODUCT_TYPES: ReadonlySet<string> = new Set([
|
|
202
|
+
'one_time_permanent',
|
|
203
|
+
'one_time_consumable',
|
|
204
|
+
'subscription',
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
/** A persisted purchase with every field checked, or null when any is wrong. */
|
|
208
|
+
function coercePurchase(input: unknown): PersistedPurchase | null {
|
|
209
|
+
if (typeof input !== 'object' || input === null) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
const record = input as Record<string, unknown>;
|
|
213
|
+
const text = (value: unknown): value is string => typeof value === 'string' && value !== '';
|
|
214
|
+
const count = (value: unknown): value is number =>
|
|
215
|
+
typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
216
|
+
if (
|
|
217
|
+
!text(record.transactionId) ||
|
|
218
|
+
!text(record.productKey) ||
|
|
219
|
+
!text(record.token) ||
|
|
220
|
+
!PRODUCT_TYPES.has(record.productType as string) ||
|
|
221
|
+
!PURCHASE_STATES.has(record.state as string) ||
|
|
222
|
+
!FINALIZE_STATES.has(record.finalizeState as string) ||
|
|
223
|
+
!VALIDATION_STATES.has(record.validationState as string) ||
|
|
224
|
+
!count(record.purchasedAt) ||
|
|
225
|
+
!count(record.attempts) ||
|
|
226
|
+
(record.expiresAt !== undefined && !count(record.expiresAt)) ||
|
|
227
|
+
(record.willAutoRenew !== undefined && typeof record.willAutoRenew !== 'boolean')
|
|
228
|
+
) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
transactionId: record.transactionId,
|
|
233
|
+
productKey: record.productKey,
|
|
234
|
+
productType: record.productType as PersistedPurchase['productType'],
|
|
235
|
+
token: record.token,
|
|
236
|
+
state: record.state as PersistedPurchaseState,
|
|
237
|
+
purchasedAt: record.purchasedAt,
|
|
238
|
+
finalizeState: record.finalizeState as PersistedFinalizeState,
|
|
239
|
+
attempts: record.attempts,
|
|
240
|
+
validationState: record.validationState as PersistedValidationState,
|
|
241
|
+
expiresAt: record.expiresAt as number | undefined,
|
|
242
|
+
willAutoRenew: record.willAutoRenew as boolean | undefined,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
171
246
|
/**
|
|
172
247
|
* Decides a purchase's finalization state from the store's view plus what we
|
|
173
248
|
* already knew.
|