@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,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provider port — **private**, never exported from `src/index.ts`.
|
|
3
|
+
*
|
|
4
|
+
* This is the boundary between the platform-neutral engine and a store-specific
|
|
5
|
+
* adapter. It exists to make three things true:
|
|
6
|
+
*
|
|
7
|
+
* 1. No store type reaches the public surface, because only the adapter ever
|
|
8
|
+
* sees one (FR-002).
|
|
9
|
+
* 2. An iOS adapter can be added by implementing this interface, changing no
|
|
10
|
+
* consumer call (FR-007).
|
|
11
|
+
* 3. The fake store is a drop-in replacement, so every outcome and ordering is
|
|
12
|
+
* testable with no device, network, or store account (NFR-007).
|
|
13
|
+
*
|
|
14
|
+
* `Provider*` types are the port's **own** normalized shapes, not the binding's.
|
|
15
|
+
* The adapter translates; nothing above it sees a `react-native-iap` value.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ProductType, Unsubscribe } from '../config/types';
|
|
19
|
+
|
|
20
|
+
/** A store's own product identifier. Confined to configuration and this layer. */
|
|
21
|
+
export type StoreProductId = string;
|
|
22
|
+
|
|
23
|
+
/** Opaque handle the store uses to identify a transaction for finalization. */
|
|
24
|
+
export type ProviderToken = string;
|
|
25
|
+
|
|
26
|
+
export type ProviderPurchaseState = 'purchased' | 'pending' | 'unspecified';
|
|
27
|
+
|
|
28
|
+
export interface ProviderOffer {
|
|
29
|
+
offerKey: string;
|
|
30
|
+
billingPeriod: string;
|
|
31
|
+
formattedPrice: string;
|
|
32
|
+
priceAmountMicros: number;
|
|
33
|
+
currencyCode: string;
|
|
34
|
+
trialPeriod?: string;
|
|
35
|
+
introductoryPrice?: {
|
|
36
|
+
formattedPrice: string;
|
|
37
|
+
period: string;
|
|
38
|
+
cycles: number;
|
|
39
|
+
};
|
|
40
|
+
/** As the **store** determined it. Never computed locally (FR-026). */
|
|
41
|
+
isEligible: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ProviderProduct {
|
|
45
|
+
storeProductId: StoreProductId;
|
|
46
|
+
productType: ProductType;
|
|
47
|
+
title: string;
|
|
48
|
+
description: string;
|
|
49
|
+
formattedPrice: string;
|
|
50
|
+
currencyCode: string;
|
|
51
|
+
priceAmountMicros: number;
|
|
52
|
+
offers: ProviderOffer[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ProviderPurchase {
|
|
56
|
+
storeProductId: StoreProductId;
|
|
57
|
+
transactionId: string;
|
|
58
|
+
token: ProviderToken;
|
|
59
|
+
state: ProviderPurchaseState;
|
|
60
|
+
purchasedAt: number;
|
|
61
|
+
isAcknowledged: boolean;
|
|
62
|
+
/** Subscription-only, and only where the platform supplies it. */
|
|
63
|
+
expiresAt?: number;
|
|
64
|
+
willAutoRenew?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Opaque material for the app's backend. Passed to the validation hook and
|
|
67
|
+
* **never** logged or put in an event (FR-074).
|
|
68
|
+
*/
|
|
69
|
+
verificationPayload?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type ProviderFailureReason =
|
|
73
|
+
| 'user_cancelled'
|
|
74
|
+
| 'already_owned'
|
|
75
|
+
| 'ineligible'
|
|
76
|
+
| 'unknown_product'
|
|
77
|
+
| 'product_unavailable_in_region'
|
|
78
|
+
| 'not_ready'
|
|
79
|
+
| 'network_error'
|
|
80
|
+
| 'timeout'
|
|
81
|
+
| 'store_error';
|
|
82
|
+
|
|
83
|
+
export type ProviderConnectOutcome =
|
|
84
|
+
| { ok: true }
|
|
85
|
+
| {
|
|
86
|
+
ok: false;
|
|
87
|
+
reason: 'no_billing_service' | 'billing_service_outdated' | 'platform_unsupported' | 'store_error';
|
|
88
|
+
retryable: boolean;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type ProviderPurchaseOutcome =
|
|
92
|
+
| { ok: true; purchase: ProviderPurchase }
|
|
93
|
+
| { ok: false; reason: ProviderFailureReason; detail?: string };
|
|
94
|
+
|
|
95
|
+
export type ProviderPriceChangeOutcome =
|
|
96
|
+
| { ok: true; confirmed: boolean }
|
|
97
|
+
| { ok: false; reason: ProviderFailureReason };
|
|
98
|
+
|
|
99
|
+
export interface ProviderPurchaseRequest {
|
|
100
|
+
storeProductId: StoreProductId;
|
|
101
|
+
productType: ProductType;
|
|
102
|
+
offerKey?: string;
|
|
103
|
+
/** App-supplied, passed straight through. Never generated or stored (FR-034). */
|
|
104
|
+
obfuscatedAccountId?: string;
|
|
105
|
+
/** Plan change: the currently-active subscription being replaced. */
|
|
106
|
+
replaceStoreProductId?: StoreProductId;
|
|
107
|
+
prorationMode?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* What the platform can actually do.
|
|
112
|
+
*
|
|
113
|
+
* This is how honest degradation is enforced rather than left to good intentions.
|
|
114
|
+
* `reportsGraceAndHoldDistinctly` is `false` on Android, because client-side Play
|
|
115
|
+
* Billing genuinely cannot separate the two: a grace-period subscription is still
|
|
116
|
+
* returned as active, and one in account hold stops being returned at all, making
|
|
117
|
+
* it indistinguishable from expired, cancelled, or refunded.
|
|
118
|
+
*
|
|
119
|
+
* The entitlement layer reads this flag and reports `indeterminate` instead of
|
|
120
|
+
* fabricating a distinction from `willAutoRenew` — which cannot separate grace
|
|
121
|
+
* from cancelled-but-valid either. Claiming otherwise would breach FR-058.
|
|
122
|
+
*/
|
|
123
|
+
export interface ProviderCapabilities {
|
|
124
|
+
supportsSubscriptions: boolean;
|
|
125
|
+
supportsPlanChange: boolean;
|
|
126
|
+
supportsPriceChangeConfirmation: boolean;
|
|
127
|
+
reportsGraceAndHoldDistinctly: boolean;
|
|
128
|
+
/** Externally-initiated purchases: false on Android, true for a future iOS adapter. */
|
|
129
|
+
supportsExternalTransactions: boolean;
|
|
130
|
+
/**
|
|
131
|
+
* Whether this adapter can move real money.
|
|
132
|
+
*
|
|
133
|
+
* `true` for the store adapter, `false` for the fake store. This is what lets
|
|
134
|
+
* FR-014's block be precise: the engine refuses a purchase when the
|
|
135
|
+
* environment may not create a production transaction **and** the provider is
|
|
136
|
+
* one that actually would. Without it the guard would either block the test
|
|
137
|
+
* suite — which must be able to buy things — or wave real purchases through in
|
|
138
|
+
* a development build.
|
|
139
|
+
*/
|
|
140
|
+
createsRealTransactions: boolean;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface ProviderPort {
|
|
144
|
+
connect(): Promise<ProviderConnectOutcome>;
|
|
145
|
+
disconnect(): Promise<void>;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Reports a **real** disconnect.
|
|
149
|
+
*
|
|
150
|
+
* The engine must never infer connectivity from an unrelated call failing.
|
|
151
|
+
* The upstream binding historically left its `connected` flag true after a
|
|
152
|
+
* disconnect and had a `reconnect()` that could report success without
|
|
153
|
+
* rebuilding anything (openiap#408), so detection belongs here, behind a
|
|
154
|
+
* contract test.
|
|
155
|
+
*/
|
|
156
|
+
observeConnection(
|
|
157
|
+
listener: (connected: boolean, reason?: string) => void,
|
|
158
|
+
): Unsubscribe;
|
|
159
|
+
|
|
160
|
+
queryProducts(
|
|
161
|
+
storeProductIds: StoreProductId[],
|
|
162
|
+
productType: ProductType,
|
|
163
|
+
): Promise<ProviderProduct[]>;
|
|
164
|
+
|
|
165
|
+
/** Owned and pending purchases. Pending ones are **flagged, never filtered** (FR-037). */
|
|
166
|
+
queryOwned(): Promise<ProviderPurchase[]>;
|
|
167
|
+
|
|
168
|
+
launchPurchase(
|
|
169
|
+
request: ProviderPurchaseRequest,
|
|
170
|
+
): Promise<ProviderPurchaseOutcome>;
|
|
171
|
+
|
|
172
|
+
/** Store-initiated updates, including a pending purchase settling later (FR-039). */
|
|
173
|
+
observePurchases(listener: (purchase: ProviderPurchase) => void): Unsubscribe;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Why a pending purchase the store has stopped reporting ended, if the store
|
|
177
|
+
* says (FR-102). Optional, and synchronous because it is asked about a
|
|
178
|
+
* transaction the store already dropped, not fetched.
|
|
179
|
+
*
|
|
180
|
+
* Play gives no reason — a declined or abandoned pending purchase simply
|
|
181
|
+
* vanishes from `queryOwned` — so the Android adapter leaves this out and the
|
|
182
|
+
* engine reports `cancelled`. It is never inferred from age: guessing the
|
|
183
|
+
* store's pending window would be inventing a fact.
|
|
184
|
+
*/
|
|
185
|
+
pendingEndReason?(transactionId: string): 'cancelled' | 'expired' | undefined;
|
|
186
|
+
|
|
187
|
+
/** Must be safe to call for an already-finalized transaction (FR-044). */
|
|
188
|
+
acknowledge(token: ProviderToken): Promise<void>;
|
|
189
|
+
/** Must be safe to call twice; the item is granted once (FR-044). */
|
|
190
|
+
consume(token: ProviderToken): Promise<void>;
|
|
191
|
+
|
|
192
|
+
openSubscriptionManagement(storeProductId?: StoreProductId): Promise<void>;
|
|
193
|
+
confirmPriceChange(
|
|
194
|
+
storeProductId: StoreProductId,
|
|
195
|
+
): Promise<ProviderPriceChangeOutcome>;
|
|
196
|
+
|
|
197
|
+
capabilities(): ProviderCapabilities;
|
|
198
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public configuration and catalog types.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here names Google Play Billing, `react-native-iap`, or any store
|
|
5
|
+
* concept (FR-002). Apps speak in **product keys** and **entitlement keys** they
|
|
6
|
+
* chose themselves; store product identifiers appear only inside catalog
|
|
7
|
+
* configuration, never in app logic (FR-003).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** An app-chosen logical entitlement, e.g. `no_ads`. What business logic asks about. */
|
|
11
|
+
export type EntitlementKey = string;
|
|
12
|
+
|
|
13
|
+
/** An app-chosen logical product, e.g. `premium_yearly`. Never a store identifier. */
|
|
14
|
+
export type ProductKey = string;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* How a product behaves once bought.
|
|
18
|
+
*
|
|
19
|
+
* `one_time_permanent` — bought once, kept forever.
|
|
20
|
+
* `one_time_consumable` — bought, used up by the app, buyable again (FR-043).
|
|
21
|
+
* `subscription` — recurring, with its own lifecycle (FR-048).
|
|
22
|
+
*/
|
|
23
|
+
export type ProductType =
|
|
24
|
+
| 'one_time_permanent'
|
|
25
|
+
| 'one_time_consumable'
|
|
26
|
+
| 'subscription';
|
|
27
|
+
|
|
28
|
+
/** Store identifiers per platform. iOS is optional and additive (FR-007). */
|
|
29
|
+
export interface StoreProductIds {
|
|
30
|
+
android?: string;
|
|
31
|
+
ios?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One product the app sells, and what it unlocks. */
|
|
35
|
+
export interface CatalogEntry {
|
|
36
|
+
productKey: ProductKey;
|
|
37
|
+
productType: ProductType;
|
|
38
|
+
storeProductIds: StoreProductIds;
|
|
39
|
+
/** Entitlement keys this product confers. Many-to-many is supported (FR-010). */
|
|
40
|
+
grants: EntitlementKey[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Separately configurable, and the basis of the production-transaction guard. */
|
|
44
|
+
export type Environment = 'development' | 'test' | 'production';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Entitlement policy for a subscription whose renewal payment failed.
|
|
48
|
+
*
|
|
49
|
+
* Kept configurable because it is a business decision, not a technical constant
|
|
50
|
+
* (FR-049). See `entitlements/policy.ts` for the defaults and why.
|
|
51
|
+
*/
|
|
52
|
+
export type LapsePolicy = 'grant' | 'withhold';
|
|
53
|
+
|
|
54
|
+
/** What to do when a configured validation hook cannot be reached (FR-072). */
|
|
55
|
+
export type ValidationUnavailablePolicy = 'grant_and_queue' | 'withhold';
|
|
56
|
+
|
|
57
|
+
/** Returned by every subscribe call. Idempotent and safe to call twice (FR-006). */
|
|
58
|
+
export type Unsubscribe = () => void;
|
|
59
|
+
|
|
60
|
+
export interface TimeoutConfig {
|
|
61
|
+
connectMs?: number;
|
|
62
|
+
productQueryMs?: number;
|
|
63
|
+
syncMs?: number;
|
|
64
|
+
/** Covers opening the store surface only. Time the user spends in it is not counted (FR-035). */
|
|
65
|
+
purchaseLaunchMs?: number;
|
|
66
|
+
finalizeAttemptMs?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface RetryConfig {
|
|
70
|
+
reconnect?: BackoffConfig;
|
|
71
|
+
finalize?: BackoffConfig;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface BackoffConfig {
|
|
75
|
+
initialDelayMs?: number;
|
|
76
|
+
maxDelayMs?: number;
|
|
77
|
+
factor?: number;
|
|
78
|
+
/** Attempts per session. Finalize is resumed next session rather than abandoned (FR-045). */
|
|
79
|
+
maxAttempts?: number;
|
|
80
|
+
jitter?: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** What the app's backend is asked to decide about (FR-069). */
|
|
84
|
+
export interface ValidationCandidate {
|
|
85
|
+
productKey: ProductKey;
|
|
86
|
+
productType: ProductType;
|
|
87
|
+
/** Opaque verification material for the backend. Never logged (FR-074). */
|
|
88
|
+
verificationPayload: string;
|
|
89
|
+
/** Ties this decision to one purchase cycle in the event stream (FR-076). */
|
|
90
|
+
correlationId: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ValidationDecision {
|
|
94
|
+
approved: boolean;
|
|
95
|
+
/** Optional app-supplied reason, surfaced as a distinguishable outcome (FR-071). */
|
|
96
|
+
reason?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type ValidationHook = (
|
|
100
|
+
candidate: ValidationCandidate,
|
|
101
|
+
) => Promise<ValidationDecision>;
|
|
102
|
+
|
|
103
|
+
/** Diagnostics sink. Deliberately not tied to any analytics vendor (FR-080). */
|
|
104
|
+
export interface LoggerAdapter {
|
|
105
|
+
debug?(message: string, meta?: Record<string, unknown>): void;
|
|
106
|
+
warn?(message: string, meta?: Record<string, unknown>): void;
|
|
107
|
+
error?(message: string, meta?: Record<string, unknown>): void;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface PaymentConfig {
|
|
111
|
+
catalog: CatalogEntry[];
|
|
112
|
+
/** Entitlement keys the app intends to query. Used for orphan detection (FR-011). */
|
|
113
|
+
entitlements: EntitlementKey[];
|
|
114
|
+
environment: Environment;
|
|
115
|
+
/**
|
|
116
|
+
* Whether the running binary is a production build.
|
|
117
|
+
*
|
|
118
|
+
* Supplied by the app because only the app knows how it was built. Drives the
|
|
119
|
+
* fail-closed guard: a non-production build declaring `production` must not
|
|
120
|
+
* proceed (FR-015).
|
|
121
|
+
*/
|
|
122
|
+
isProductionBuild: boolean;
|
|
123
|
+
/**
|
|
124
|
+
* Global sell switch. When false, purchases report `disabled` but entitlements
|
|
125
|
+
* already held stay readable — turning off selling is not the same as taking
|
|
126
|
+
* away what someone bought (FR-012).
|
|
127
|
+
*/
|
|
128
|
+
enabled?: boolean;
|
|
129
|
+
gracePolicy?: LapsePolicy;
|
|
130
|
+
holdPolicy?: LapsePolicy;
|
|
131
|
+
staleAfterMs?: number;
|
|
132
|
+
productCacheTtlMs?: number;
|
|
133
|
+
foregroundSyncMinIntervalMs?: number;
|
|
134
|
+
timeouts?: TimeoutConfig;
|
|
135
|
+
retry?: RetryConfig;
|
|
136
|
+
/**
|
|
137
|
+
* Opaque cache-partition key (FR-057a).
|
|
138
|
+
*
|
|
139
|
+
* Not a user identifier: the package never interprets it, transmits it, or
|
|
140
|
+
* attaches it to a transaction. Changing it makes the previous snapshot
|
|
141
|
+
* unreadable, so a second app-account user cannot inherit the first one's
|
|
142
|
+
* entitlements even if the app forgets to call `clearLocalData`.
|
|
143
|
+
*/
|
|
144
|
+
scopeToken?: string;
|
|
145
|
+
validateTransaction?: ValidationHook;
|
|
146
|
+
onValidationUnavailable?: ValidationUnavailablePolicy;
|
|
147
|
+
logger?: LoggerAdapter;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Opaque handle for a transaction. Exposes no raw store record (FR-051). */
|
|
151
|
+
export interface TransactionRef {
|
|
152
|
+
readonly transactionId: string;
|
|
153
|
+
readonly productKey: ProductKey;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface PurchaseOptions {
|
|
157
|
+
/**
|
|
158
|
+
* The app's obfuscated account identifier, passed straight through to the
|
|
159
|
+
* store for fraud prevention. Never generated, inferred, or persisted by the
|
|
160
|
+
* package (FR-034), and distinct from `scopeToken`.
|
|
161
|
+
*/
|
|
162
|
+
obfuscatedAccountId?: string;
|
|
163
|
+
/** Which pricing option to buy, for a subscription with several offers. */
|
|
164
|
+
offerKey?: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Every way a purchase can end (FR-005).
|
|
169
|
+
*
|
|
170
|
+
* A discriminated union rather than a boolean plus an error, because the app has
|
|
171
|
+
* to tell these apart: `user_cancelled` is a normal outcome to stay quiet about,
|
|
172
|
+
* `pending` means wait, `already_owned` means re-sync, and `store_error` means
|
|
173
|
+
* show something. Collapsing them is how apps end up reporting a cancellation as
|
|
174
|
+
* a failure.
|
|
175
|
+
*/
|
|
176
|
+
export type PurchaseResult =
|
|
177
|
+
| { kind: 'success'; productKey: ProductKey; transaction: TransactionRef }
|
|
178
|
+
/** Money has not arrived. No entitlement is granted (FR-037). */
|
|
179
|
+
| { kind: 'pending'; productKey: ProductKey; transaction: TransactionRef }
|
|
180
|
+
/** Normal, not an error. Must never be recorded as a technical failure (FR-032). */
|
|
181
|
+
| { kind: 'user_cancelled'; productKey: ProductKey }
|
|
182
|
+
/** Triggers a re-sync: this is how a reinstall repairs itself (FR-033). */
|
|
183
|
+
| { kind: 'already_owned'; productKey: ProductKey }
|
|
184
|
+
| { kind: 'ineligible'; productKey: ProductKey }
|
|
185
|
+
| { kind: 'unknown_product'; productKey: ProductKey }
|
|
186
|
+
| { kind: 'product_unavailable_in_region'; productKey: ProductKey }
|
|
187
|
+
/** Global sell switch is off (FR-012). */
|
|
188
|
+
| { kind: 'disabled' }
|
|
189
|
+
| { kind: 'not_ready'; status: PaymentStatusKind }
|
|
190
|
+
| { kind: 'unsupported' }
|
|
191
|
+
/** A second purchase while one is open. Refused, never queued (FR-031). */
|
|
192
|
+
| { kind: 'purchase_in_progress' }
|
|
193
|
+
/** Refused because this build may not create a production transaction (FR-014). */
|
|
194
|
+
| { kind: 'environment_blocked'; productKey: ProductKey; environment: Environment }
|
|
195
|
+
/** The app's backend said no (FR-071). Distinguishable from a network failure. */
|
|
196
|
+
| { kind: 'validation_rejected'; productKey: ProductKey; reason?: string }
|
|
197
|
+
| { kind: 'timeout'; productKey: ProductKey }
|
|
198
|
+
| { kind: 'network_error'; productKey: ProductKey }
|
|
199
|
+
| { kind: 'store_error'; productKey: ProductKey; code: string };
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* A purchase whose money has not arrived yet (FR-101), added in 0.2.0.
|
|
203
|
+
*
|
|
204
|
+
* One entry per product key, whatever number of pending transactions the store
|
|
205
|
+
* reports for it. Carries no transaction id, token, or store object (FR-051) —
|
|
206
|
+
* enough to render "payment pending" and to withhold Buy, nothing to act on.
|
|
207
|
+
*/
|
|
208
|
+
export interface PendingPurchase {
|
|
209
|
+
readonly productKey: ProductKey;
|
|
210
|
+
/** When the store reports the purchase was made, in epoch milliseconds. Earliest if several. */
|
|
211
|
+
readonly since: number;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The pending set, with how far it can be trusted (FR-101, FR-103).
|
|
216
|
+
*
|
|
217
|
+
* Read `status` first. `unknown` follows the entitlement rule exactly: never
|
|
218
|
+
* synced, or synced longer ago than `staleAfterMs`. Under `unknown`, `purchases`
|
|
219
|
+
* still lists what the last good sync saw, but an empty list there does **not**
|
|
220
|
+
* mean "nothing is pending" — the same reason an `unknown` entitlement is not
|
|
221
|
+
* `not_entitled`.
|
|
222
|
+
*/
|
|
223
|
+
export interface PendingPurchasesState {
|
|
224
|
+
readonly status: 'known' | 'unknown';
|
|
225
|
+
readonly purchases: readonly PendingPurchase[];
|
|
226
|
+
/** True once the snapshot is older than `staleAfterMs`; `status` is then `unknown`. */
|
|
227
|
+
readonly isStale: boolean;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* How a pending purchase ended (FR-102).
|
|
232
|
+
*
|
|
233
|
+
* `settled` — the money arrived; an entitlement grant, if the product confers one,
|
|
234
|
+
* follows. `expired` — the store reported that the pending window ran out.
|
|
235
|
+
* `cancelled` — it ended without payment for any other reason, **including when
|
|
236
|
+
* the store gives none**: Play simply stops reporting a declined or abandoned
|
|
237
|
+
* pending purchase, so on Android every unpaid end is `cancelled`.
|
|
238
|
+
*/
|
|
239
|
+
export type PendingResolution = 'settled' | 'cancelled' | 'expired';
|
|
240
|
+
|
|
241
|
+
/** Outcome of finalizing or consuming a transaction (FR-044). */
|
|
242
|
+
export type FinalizeResult =
|
|
243
|
+
| { kind: 'finalized' }
|
|
244
|
+
/** Already done. Idempotent by design, so this is a success, not an error. */
|
|
245
|
+
| { kind: 'already_finalized' }
|
|
246
|
+
| { kind: 'unknown_transaction' }
|
|
247
|
+
/** Queued for bounded retry; the condition stays observable (FR-045). */
|
|
248
|
+
| { kind: 'retry_scheduled'; attempts: number }
|
|
249
|
+
| { kind: 'failed'; code: string };
|
|
250
|
+
|
|
251
|
+
/** A paid transaction not yet settled with the store, surfaced so it is not swallowed. */
|
|
252
|
+
export interface UnfinalizedSummary {
|
|
253
|
+
productKey: ProductKey;
|
|
254
|
+
transactionId: string;
|
|
255
|
+
attempts: number;
|
|
256
|
+
purchasedAt: number;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Observable package status (FR-019). */
|
|
260
|
+
export type PaymentStatusKind =
|
|
261
|
+
| 'not_started'
|
|
262
|
+
| 'connecting'
|
|
263
|
+
| 'ready'
|
|
264
|
+
| 'disabled'
|
|
265
|
+
| 'unavailable'
|
|
266
|
+
| 'failed';
|
|
267
|
+
|
|
268
|
+
/** Why selling is impossible here, kept distinguishable so apps can tell users why (FR-021). */
|
|
269
|
+
export type UnavailableReason =
|
|
270
|
+
| 'no_billing_service'
|
|
271
|
+
| 'billing_service_outdated'
|
|
272
|
+
| 'platform_unsupported';
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* A purchase the package refused because it could have been a real charge
|
|
276
|
+
* outside production (FR-014).
|
|
277
|
+
*
|
|
278
|
+
* Exposed in status rather than only logged so an automated test can assert the
|
|
279
|
+
* block happened, which is the difference between a guarantee and a hope.
|
|
280
|
+
*/
|
|
281
|
+
export interface BlockedOperation {
|
|
282
|
+
productKey: ProductKey;
|
|
283
|
+
environment: Environment;
|
|
284
|
+
at: number;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export interface PaymentStatus {
|
|
288
|
+
kind: PaymentStatusKind;
|
|
289
|
+
unavailableReason?: UnavailableReason;
|
|
290
|
+
/** Whether retrying initialization could plausibly help (FR-024). */
|
|
291
|
+
retryable?: boolean;
|
|
292
|
+
/** Reconnect attempts made in this session, so a retry storm is visible (FR-020). */
|
|
293
|
+
reconnectAttempts?: number;
|
|
294
|
+
blockedOperations: BlockedOperation[];
|
|
295
|
+
}
|