@chipmobilesdk/rn-payment 0.1.0 → 0.2.1
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 +113 -1
- package/README.md +885 -761
- package/package.json +54 -54
- package/skills/sdk-rn-payment/SKILL.md +33 -6
- package/src/adapter/createStoreProvider.ts +117 -117
- package/src/adapter/iap-adapter.ts +547 -516
- package/src/adapter/port.ts +12 -0
- package/src/config/types.ts +40 -0
- package/src/diagnostics/events.ts +9 -0
- package/src/engine/engine.ts +951 -854
- package/src/engine/finalize.ts +298 -298
- package/src/engine/pending.ts +118 -0
- package/src/engine/products.ts +259 -259
- package/src/engine/purchase.ts +526 -495
- package/src/engine/subscriptions.ts +157 -157
- package/src/entitlements/aggregate.ts +21 -4
- package/src/index.ts +4 -0
- package/src/persistence/mmkv-store.ts +16 -8
- package/src/react/hooks.ts +274 -238
- package/src/testing/fakeStore.ts +33 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pending set, and how a pending purchase ends (FR-101 – FR-104), added in
|
|
3
|
+
* 0.2.0.
|
|
4
|
+
*
|
|
5
|
+
* 0.1.0 handled pending purchases correctly on the grant side — pending never
|
|
6
|
+
* grants, a settled one is granted at the next sync, an abandoned one is removed
|
|
7
|
+
* — but an app could only *see* one in the session it started in. The record
|
|
8
|
+
* was already persisted with a `pending` state; this module is the read side of
|
|
9
|
+
* it, and nothing more. It decides nothing about granting: that stays in
|
|
10
|
+
* `entitlements/aggregate.ts`, where pending is refused unconditionally.
|
|
11
|
+
*
|
|
12
|
+
* Pure: no provider, no I/O, no React. The engine calls it from `commit`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ResolvedConfig } from '../config/validate';
|
|
16
|
+
import type {
|
|
17
|
+
PendingPurchase,
|
|
18
|
+
PendingPurchasesState,
|
|
19
|
+
PendingResolution,
|
|
20
|
+
ProductKey,
|
|
21
|
+
} from '../config/types';
|
|
22
|
+
import type { PersistedSnapshot } from '../persistence/snapshot';
|
|
23
|
+
import { snapshotCertainty } from '../entitlements/aggregate';
|
|
24
|
+
|
|
25
|
+
export const UNKNOWN_PENDING: PendingPurchasesState = {
|
|
26
|
+
status: 'unknown',
|
|
27
|
+
purchases: [],
|
|
28
|
+
isStale: false,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Derives the pending set from the snapshot.
|
|
33
|
+
*
|
|
34
|
+
* Read from the persisted record, so it survives an app kill and an offline
|
|
35
|
+
* restart, and judged by the very staleness rule entitlements use (FR-103).
|
|
36
|
+
* Products no longer in the catalog are left out: an app cannot render a row,
|
|
37
|
+
* or withhold a Buy button, for a product key it no longer has.
|
|
38
|
+
*/
|
|
39
|
+
export function derivePending(input: {
|
|
40
|
+
config: ResolvedConfig;
|
|
41
|
+
snapshot: PersistedSnapshot;
|
|
42
|
+
hasSynced: boolean;
|
|
43
|
+
now: number;
|
|
44
|
+
}): PendingPurchasesState {
|
|
45
|
+
const { config, snapshot } = input;
|
|
46
|
+
const { isStale, undetermined } = snapshotCertainty(input);
|
|
47
|
+
|
|
48
|
+
const earliest = new Map<ProductKey, number>();
|
|
49
|
+
for (const purchase of snapshot.purchases) {
|
|
50
|
+
if (purchase.state !== 'pending' || !config.entries.has(purchase.productKey)) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const known = earliest.get(purchase.productKey);
|
|
54
|
+
if (known === undefined || purchase.purchasedAt < known) {
|
|
55
|
+
earliest.set(purchase.productKey, purchase.purchasedAt);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const purchases: PendingPurchase[] = [...earliest]
|
|
60
|
+
.map(([productKey, since]) => ({ productKey, since }))
|
|
61
|
+
.sort((a, b) => a.since - b.since || a.productKey.localeCompare(b.productKey));
|
|
62
|
+
|
|
63
|
+
return { status: undetermined ? 'unknown' : 'known', purchases, isStale };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface PendingTransition {
|
|
67
|
+
readonly productKey: ProductKey;
|
|
68
|
+
readonly outcome: PendingResolution;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every pending transaction that stopped being pending between two snapshots.
|
|
73
|
+
*
|
|
74
|
+
* Computed from the commit itself rather than at each call site that might end
|
|
75
|
+
* a pending purchase — sync, a store push, a purchase — which is what makes
|
|
76
|
+
* "exactly once" structural (FR-104, FR-079). A store push and a sync racing on
|
|
77
|
+
* one settling purchase both commit, but only the first commit sees it leave
|
|
78
|
+
* the pending set.
|
|
79
|
+
*
|
|
80
|
+
* A record that disappeared ended without payment. Why is the store's to say;
|
|
81
|
+
* when it says nothing, `cancelled` (see `PendingResolution`).
|
|
82
|
+
*/
|
|
83
|
+
export function pendingTransitions(
|
|
84
|
+
previous: PersistedSnapshot,
|
|
85
|
+
next: PersistedSnapshot,
|
|
86
|
+
config: ResolvedConfig,
|
|
87
|
+
endReason: (transactionId: string) => 'cancelled' | 'expired' | undefined,
|
|
88
|
+
): PendingTransition[] {
|
|
89
|
+
const transitions: PendingTransition[] = [];
|
|
90
|
+
for (const before of previous.purchases) {
|
|
91
|
+
if (before.state !== 'pending' || !config.entries.has(before.productKey)) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const after = next.purchases.find(p => p.transactionId === before.transactionId);
|
|
95
|
+
if (after?.state === 'pending') {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
transitions.push({
|
|
99
|
+
productKey: before.productKey,
|
|
100
|
+
outcome: after ? 'settled' : (endReason(before.transactionId) ?? 'cancelled'),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return transitions;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether a listener has anything new to hear. */
|
|
107
|
+
export function samePending(a: PendingPurchasesState, b: PendingPurchasesState): boolean {
|
|
108
|
+
return (
|
|
109
|
+
a.status === b.status &&
|
|
110
|
+
a.isStale === b.isStale &&
|
|
111
|
+
a.purchases.length === b.purchases.length &&
|
|
112
|
+
a.purchases.every(
|
|
113
|
+
(purchase, index) =>
|
|
114
|
+
purchase.productKey === b.purchases[index].productKey &&
|
|
115
|
+
purchase.since === b.purchases[index].since,
|
|
116
|
+
)
|
|
117
|
+
);
|
|
118
|
+
}
|
package/src/engine/products.ts
CHANGED
|
@@ -1,259 +1,259 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Product query, normalization, and the in-memory price cache (US11).
|
|
3
|
-
*
|
|
4
|
-
* Two rules shape this module:
|
|
5
|
-
*
|
|
6
|
-
* - **Prices come from the store, formatted.** An app must never build a price
|
|
7
|
-
* string (FR-028): only the store knows the right currency symbol, its
|
|
8
|
-
* placement, the decimal separator, and the grouping for the user's locale. A
|
|
9
|
-
* hand-built "$9.99" is wrong in most of the world.
|
|
10
|
-
* - **Offer eligibility is reported, never computed.** The store decides who
|
|
11
|
-
* qualifies for a trial or an introductory price (FR-026). Guessing locally
|
|
12
|
-
* would promise a trial the store then refuses at checkout.
|
|
13
|
-
*
|
|
14
|
-
* The cache is in **memory only** and short-lived. Persisting prices would risk
|
|
15
|
-
* showing a stale one as current in a later session, and a paywall is exactly
|
|
16
|
-
* where that matters.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import type { ProductKey, ProductType } from '../config/types';
|
|
20
|
-
import type { ResolvedConfig } from '../config/validate';
|
|
21
|
-
import type { ProviderPort, ProviderProduct } from '../adapter/port';
|
|
22
|
-
import { PaymentError } from '../diagnostics/errors';
|
|
23
|
-
import { withTimeout } from '../core/mutex';
|
|
24
|
-
import type { PaymentEventInput } from '../diagnostics/events';
|
|
25
|
-
import { storeIdForProductKey } from '../entitlements/mapping';
|
|
26
|
-
|
|
27
|
-
export interface Offer {
|
|
28
|
-
offerKey: string;
|
|
29
|
-
billingPeriod: string;
|
|
30
|
-
formattedPrice: string;
|
|
31
|
-
priceAmountMicros: number;
|
|
32
|
-
currencyCode: string;
|
|
33
|
-
trialPeriod?: string;
|
|
34
|
-
introductoryPrice?: {
|
|
35
|
-
formattedPrice: string;
|
|
36
|
-
period: string;
|
|
37
|
-
cycles: number;
|
|
38
|
-
};
|
|
39
|
-
/** As the store determined it. Never computed here (FR-026). */
|
|
40
|
-
isEligible: boolean;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface ProductInfo {
|
|
44
|
-
productKey: ProductKey;
|
|
45
|
-
productType: ProductType;
|
|
46
|
-
title: string;
|
|
47
|
-
description: string;
|
|
48
|
-
/** Store-formatted and locale-correct. For display (FR-024). */
|
|
49
|
-
formattedPrice: string;
|
|
50
|
-
currencyCode: string;
|
|
51
|
-
/** For comparison and sorting only — never for display (FR-028). */
|
|
52
|
-
priceAmountMicros: number;
|
|
53
|
-
offers: Offer[];
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Why a requested product produced nothing, kept distinct from a network error. */
|
|
57
|
-
export type MissingProductReason =
|
|
58
|
-
| 'not_in_catalog'
|
|
59
|
-
/** Does not exist, is unapproved, or is not sold in this country (FR-027). */
|
|
60
|
-
| 'not_available_from_store';
|
|
61
|
-
|
|
62
|
-
export interface MissingProduct {
|
|
63
|
-
productKey: ProductKey;
|
|
64
|
-
reason: MissingProductReason;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export type ProductQueryResult =
|
|
68
|
-
| {
|
|
69
|
-
ok: true;
|
|
70
|
-
products: ProductInfo[];
|
|
71
|
-
/** Reported separately so an app shows an explanation, not an empty row. */
|
|
72
|
-
missing: MissingProduct[];
|
|
73
|
-
fromCache: boolean;
|
|
74
|
-
}
|
|
75
|
-
| { ok: false; error: PaymentError };
|
|
76
|
-
|
|
77
|
-
export interface ProductsDeps {
|
|
78
|
-
resolved: ResolvedConfig;
|
|
79
|
-
provider: ProviderPort;
|
|
80
|
-
emit: (event: PaymentEventInput) => void;
|
|
81
|
-
now: () => number;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export interface Products {
|
|
85
|
-
get(productKeys: ProductKey[]): Promise<ProductQueryResult>;
|
|
86
|
-
/** Bypasses the cache; for a pull-to-refresh on a paywall (FR-029). */
|
|
87
|
-
refresh(productKeys: ProductKey[]): Promise<ProductQueryResult>;
|
|
88
|
-
/** Drops the cache, e.g. after a locale or account change. */
|
|
89
|
-
invalidate(): void;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
interface CacheEntry {
|
|
93
|
-
product: ProductInfo;
|
|
94
|
-
cachedAt: number;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function normalize(
|
|
98
|
-
raw: ProviderProduct,
|
|
99
|
-
productKey: ProductKey,
|
|
100
|
-
productType: ProductType,
|
|
101
|
-
): ProductInfo {
|
|
102
|
-
return {
|
|
103
|
-
productKey,
|
|
104
|
-
productType,
|
|
105
|
-
title: raw.title,
|
|
106
|
-
description: raw.description,
|
|
107
|
-
formattedPrice: raw.formattedPrice,
|
|
108
|
-
currencyCode: raw.currencyCode,
|
|
109
|
-
priceAmountMicros: raw.priceAmountMicros,
|
|
110
|
-
offers: raw.offers.map(offer => ({
|
|
111
|
-
offerKey: offer.offerKey,
|
|
112
|
-
billingPeriod: offer.billingPeriod,
|
|
113
|
-
formattedPrice: offer.formattedPrice,
|
|
114
|
-
priceAmountMicros: offer.priceAmountMicros,
|
|
115
|
-
currencyCode: offer.currencyCode,
|
|
116
|
-
trialPeriod: offer.trialPeriod,
|
|
117
|
-
introductoryPrice: offer.introductoryPrice,
|
|
118
|
-
isEligible: offer.isEligible,
|
|
119
|
-
})),
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export function createProducts(deps: ProductsDeps): Products {
|
|
124
|
-
const { resolved } = deps;
|
|
125
|
-
const cache = new Map<ProductKey, CacheEntry>();
|
|
126
|
-
|
|
127
|
-
const isFresh = (entry: CacheEntry): boolean =>
|
|
128
|
-
deps.now() - entry.cachedAt < resolved.productCacheTtlMs;
|
|
129
|
-
|
|
130
|
-
const query = async (
|
|
131
|
-
productKeys: ProductKey[],
|
|
132
|
-
useCache: boolean,
|
|
133
|
-
): Promise<ProductQueryResult> => {
|
|
134
|
-
const missing: MissingProduct[] = [];
|
|
135
|
-
const resolvedProducts: ProductInfo[] = [];
|
|
136
|
-
const toFetch: Array<{
|
|
137
|
-
productKey: ProductKey;
|
|
138
|
-
storeProductId: string;
|
|
139
|
-
productType: ProductType;
|
|
140
|
-
}> = [];
|
|
141
|
-
|
|
142
|
-
for (const productKey of productKeys) {
|
|
143
|
-
const entry = resolved.entries.get(productKey);
|
|
144
|
-
const storeProductId = storeIdForProductKey(resolved, productKey);
|
|
145
|
-
if (!entry || !storeProductId) {
|
|
146
|
-
// A configuration problem, not a store one. Distinguishing them tells a
|
|
147
|
-
// developer whether to fix the catalog or check Play Console.
|
|
148
|
-
missing.push({ productKey, reason: 'not_in_catalog' });
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const cached = useCache ? cache.get(productKey) : undefined;
|
|
153
|
-
if (cached && isFresh(cached)) {
|
|
154
|
-
resolvedProducts.push(cached.product);
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
toFetch.push({
|
|
159
|
-
productKey,
|
|
160
|
-
storeProductId,
|
|
161
|
-
productType: entry.productType,
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
if (toFetch.length === 0) {
|
|
166
|
-
deps.emit({
|
|
167
|
-
type: 'products_queried',
|
|
168
|
-
productKeys,
|
|
169
|
-
found: resolvedProducts.length,
|
|
170
|
-
missing: missing.map(m => m.productKey),
|
|
171
|
-
fromCache: true,
|
|
172
|
-
});
|
|
173
|
-
return {
|
|
174
|
-
ok: true,
|
|
175
|
-
products: resolvedProducts,
|
|
176
|
-
missing,
|
|
177
|
-
fromCache: true,
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Grouped by product type because the store queries in-app products and
|
|
182
|
-
// subscriptions through separate calls.
|
|
183
|
-
const byType = new Map<ProductType, typeof toFetch>();
|
|
184
|
-
for (const item of toFetch) {
|
|
185
|
-
const list = byType.get(item.productType) ?? [];
|
|
186
|
-
list.push(item);
|
|
187
|
-
byType.set(item.productType, list);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
try {
|
|
191
|
-
for (const [productType, items] of byType) {
|
|
192
|
-
const raw = await withTimeout(
|
|
193
|
-
deps.provider.queryProducts(
|
|
194
|
-
items.map(i => i.storeProductId),
|
|
195
|
-
productType,
|
|
196
|
-
),
|
|
197
|
-
resolved.timeouts.productQueryMs,
|
|
198
|
-
() =>
|
|
199
|
-
new PaymentError({
|
|
200
|
-
code: 'PAYMENT_TIMEOUT',
|
|
201
|
-
message: 'Querying products timed out',
|
|
202
|
-
operation: 'getProducts',
|
|
203
|
-
retryable: true,
|
|
204
|
-
}),
|
|
205
|
-
);
|
|
206
|
-
|
|
207
|
-
const returned = new Map(raw.map(p => [p.storeProductId, p]));
|
|
208
|
-
for (const item of items) {
|
|
209
|
-
const found = returned.get(item.storeProductId);
|
|
210
|
-
if (!found) {
|
|
211
|
-
// The store knows the id but will not sell it here: unapproved, or
|
|
212
|
-
// not available in this country. Never conflated with a network
|
|
213
|
-
// failure (FR-027).
|
|
214
|
-
missing.push({
|
|
215
|
-
productKey: item.productKey,
|
|
216
|
-
reason: 'not_available_from_store',
|
|
217
|
-
});
|
|
218
|
-
continue;
|
|
219
|
-
}
|
|
220
|
-
const product = normalize(found, item.productKey, item.productType);
|
|
221
|
-
cache.set(item.productKey, { product, cachedAt: deps.now() });
|
|
222
|
-
resolvedProducts.push(product);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
} catch (error) {
|
|
226
|
-
const paymentError =
|
|
227
|
-
error instanceof PaymentError
|
|
228
|
-
? error
|
|
229
|
-
: new PaymentError({
|
|
230
|
-
code: 'PAYMENT_NETWORK_ERROR',
|
|
231
|
-
message: 'Could not reach the store to query products',
|
|
232
|
-
operation: 'getProducts',
|
|
233
|
-
retryable: true,
|
|
234
|
-
});
|
|
235
|
-
deps.emit({
|
|
236
|
-
type: 'internal_error',
|
|
237
|
-
code: paymentError.code,
|
|
238
|
-
operation: 'getProducts',
|
|
239
|
-
});
|
|
240
|
-
return { ok: false, error: paymentError };
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
deps.emit({
|
|
244
|
-
type: 'products_queried',
|
|
245
|
-
productKeys,
|
|
246
|
-
found: resolvedProducts.length,
|
|
247
|
-
missing: missing.map(m => m.productKey),
|
|
248
|
-
fromCache: false,
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
return { ok: true, products: resolvedProducts, missing, fromCache: false };
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
return {
|
|
255
|
-
get: productKeys => query(productKeys, true),
|
|
256
|
-
refresh: productKeys => query(productKeys, false),
|
|
257
|
-
invalidate: () => cache.clear(),
|
|
258
|
-
};
|
|
259
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Product query, normalization, and the in-memory price cache (US11).
|
|
3
|
+
*
|
|
4
|
+
* Two rules shape this module:
|
|
5
|
+
*
|
|
6
|
+
* - **Prices come from the store, formatted.** An app must never build a price
|
|
7
|
+
* string (FR-028): only the store knows the right currency symbol, its
|
|
8
|
+
* placement, the decimal separator, and the grouping for the user's locale. A
|
|
9
|
+
* hand-built "$9.99" is wrong in most of the world.
|
|
10
|
+
* - **Offer eligibility is reported, never computed.** The store decides who
|
|
11
|
+
* qualifies for a trial or an introductory price (FR-026). Guessing locally
|
|
12
|
+
* would promise a trial the store then refuses at checkout.
|
|
13
|
+
*
|
|
14
|
+
* The cache is in **memory only** and short-lived. Persisting prices would risk
|
|
15
|
+
* showing a stale one as current in a later session, and a paywall is exactly
|
|
16
|
+
* where that matters.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ProductKey, ProductType } from '../config/types';
|
|
20
|
+
import type { ResolvedConfig } from '../config/validate';
|
|
21
|
+
import type { ProviderPort, ProviderProduct } from '../adapter/port';
|
|
22
|
+
import { PaymentError } from '../diagnostics/errors';
|
|
23
|
+
import { withTimeout } from '../core/mutex';
|
|
24
|
+
import type { PaymentEventInput } from '../diagnostics/events';
|
|
25
|
+
import { storeIdForProductKey } from '../entitlements/mapping';
|
|
26
|
+
|
|
27
|
+
export interface Offer {
|
|
28
|
+
offerKey: string;
|
|
29
|
+
billingPeriod: string;
|
|
30
|
+
formattedPrice: string;
|
|
31
|
+
priceAmountMicros: number;
|
|
32
|
+
currencyCode: string;
|
|
33
|
+
trialPeriod?: string;
|
|
34
|
+
introductoryPrice?: {
|
|
35
|
+
formattedPrice: string;
|
|
36
|
+
period: string;
|
|
37
|
+
cycles: number;
|
|
38
|
+
};
|
|
39
|
+
/** As the store determined it. Never computed here (FR-026). */
|
|
40
|
+
isEligible: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ProductInfo {
|
|
44
|
+
productKey: ProductKey;
|
|
45
|
+
productType: ProductType;
|
|
46
|
+
title: string;
|
|
47
|
+
description: string;
|
|
48
|
+
/** Store-formatted and locale-correct. For display (FR-024). */
|
|
49
|
+
formattedPrice: string;
|
|
50
|
+
currencyCode: string;
|
|
51
|
+
/** For comparison and sorting only — never for display (FR-028). */
|
|
52
|
+
priceAmountMicros: number;
|
|
53
|
+
offers: Offer[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Why a requested product produced nothing, kept distinct from a network error. */
|
|
57
|
+
export type MissingProductReason =
|
|
58
|
+
| 'not_in_catalog'
|
|
59
|
+
/** Does not exist, is unapproved, or is not sold in this country (FR-027). */
|
|
60
|
+
| 'not_available_from_store';
|
|
61
|
+
|
|
62
|
+
export interface MissingProduct {
|
|
63
|
+
productKey: ProductKey;
|
|
64
|
+
reason: MissingProductReason;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type ProductQueryResult =
|
|
68
|
+
| {
|
|
69
|
+
ok: true;
|
|
70
|
+
products: ProductInfo[];
|
|
71
|
+
/** Reported separately so an app shows an explanation, not an empty row. */
|
|
72
|
+
missing: MissingProduct[];
|
|
73
|
+
fromCache: boolean;
|
|
74
|
+
}
|
|
75
|
+
| { ok: false; error: PaymentError };
|
|
76
|
+
|
|
77
|
+
export interface ProductsDeps {
|
|
78
|
+
resolved: ResolvedConfig;
|
|
79
|
+
provider: ProviderPort;
|
|
80
|
+
emit: (event: PaymentEventInput) => void;
|
|
81
|
+
now: () => number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface Products {
|
|
85
|
+
get(productKeys: ProductKey[]): Promise<ProductQueryResult>;
|
|
86
|
+
/** Bypasses the cache; for a pull-to-refresh on a paywall (FR-029). */
|
|
87
|
+
refresh(productKeys: ProductKey[]): Promise<ProductQueryResult>;
|
|
88
|
+
/** Drops the cache, e.g. after a locale or account change. */
|
|
89
|
+
invalidate(): void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface CacheEntry {
|
|
93
|
+
product: ProductInfo;
|
|
94
|
+
cachedAt: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalize(
|
|
98
|
+
raw: ProviderProduct,
|
|
99
|
+
productKey: ProductKey,
|
|
100
|
+
productType: ProductType,
|
|
101
|
+
): ProductInfo {
|
|
102
|
+
return {
|
|
103
|
+
productKey,
|
|
104
|
+
productType,
|
|
105
|
+
title: raw.title,
|
|
106
|
+
description: raw.description,
|
|
107
|
+
formattedPrice: raw.formattedPrice,
|
|
108
|
+
currencyCode: raw.currencyCode,
|
|
109
|
+
priceAmountMicros: raw.priceAmountMicros,
|
|
110
|
+
offers: raw.offers.map(offer => ({
|
|
111
|
+
offerKey: offer.offerKey,
|
|
112
|
+
billingPeriod: offer.billingPeriod,
|
|
113
|
+
formattedPrice: offer.formattedPrice,
|
|
114
|
+
priceAmountMicros: offer.priceAmountMicros,
|
|
115
|
+
currencyCode: offer.currencyCode,
|
|
116
|
+
trialPeriod: offer.trialPeriod,
|
|
117
|
+
introductoryPrice: offer.introductoryPrice,
|
|
118
|
+
isEligible: offer.isEligible,
|
|
119
|
+
})),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function createProducts(deps: ProductsDeps): Products {
|
|
124
|
+
const { resolved } = deps;
|
|
125
|
+
const cache = new Map<ProductKey, CacheEntry>();
|
|
126
|
+
|
|
127
|
+
const isFresh = (entry: CacheEntry): boolean =>
|
|
128
|
+
deps.now() - entry.cachedAt < resolved.productCacheTtlMs;
|
|
129
|
+
|
|
130
|
+
const query = async (
|
|
131
|
+
productKeys: ProductKey[],
|
|
132
|
+
useCache: boolean,
|
|
133
|
+
): Promise<ProductQueryResult> => {
|
|
134
|
+
const missing: MissingProduct[] = [];
|
|
135
|
+
const resolvedProducts: ProductInfo[] = [];
|
|
136
|
+
const toFetch: Array<{
|
|
137
|
+
productKey: ProductKey;
|
|
138
|
+
storeProductId: string;
|
|
139
|
+
productType: ProductType;
|
|
140
|
+
}> = [];
|
|
141
|
+
|
|
142
|
+
for (const productKey of productKeys) {
|
|
143
|
+
const entry = resolved.entries.get(productKey);
|
|
144
|
+
const storeProductId = storeIdForProductKey(resolved, productKey);
|
|
145
|
+
if (!entry || !storeProductId) {
|
|
146
|
+
// A configuration problem, not a store one. Distinguishing them tells a
|
|
147
|
+
// developer whether to fix the catalog or check Play Console.
|
|
148
|
+
missing.push({ productKey, reason: 'not_in_catalog' });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const cached = useCache ? cache.get(productKey) : undefined;
|
|
153
|
+
if (cached && isFresh(cached)) {
|
|
154
|
+
resolvedProducts.push(cached.product);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
toFetch.push({
|
|
159
|
+
productKey,
|
|
160
|
+
storeProductId,
|
|
161
|
+
productType: entry.productType,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (toFetch.length === 0) {
|
|
166
|
+
deps.emit({
|
|
167
|
+
type: 'products_queried',
|
|
168
|
+
productKeys,
|
|
169
|
+
found: resolvedProducts.length,
|
|
170
|
+
missing: missing.map(m => m.productKey),
|
|
171
|
+
fromCache: true,
|
|
172
|
+
});
|
|
173
|
+
return {
|
|
174
|
+
ok: true,
|
|
175
|
+
products: resolvedProducts,
|
|
176
|
+
missing,
|
|
177
|
+
fromCache: true,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Grouped by product type because the store queries in-app products and
|
|
182
|
+
// subscriptions through separate calls.
|
|
183
|
+
const byType = new Map<ProductType, typeof toFetch>();
|
|
184
|
+
for (const item of toFetch) {
|
|
185
|
+
const list = byType.get(item.productType) ?? [];
|
|
186
|
+
list.push(item);
|
|
187
|
+
byType.set(item.productType, list);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
for (const [productType, items] of byType) {
|
|
192
|
+
const raw = await withTimeout(
|
|
193
|
+
deps.provider.queryProducts(
|
|
194
|
+
items.map(i => i.storeProductId),
|
|
195
|
+
productType,
|
|
196
|
+
),
|
|
197
|
+
resolved.timeouts.productQueryMs,
|
|
198
|
+
() =>
|
|
199
|
+
new PaymentError({
|
|
200
|
+
code: 'PAYMENT_TIMEOUT',
|
|
201
|
+
message: 'Querying products timed out',
|
|
202
|
+
operation: 'getProducts',
|
|
203
|
+
retryable: true,
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const returned = new Map(raw.map(p => [p.storeProductId, p]));
|
|
208
|
+
for (const item of items) {
|
|
209
|
+
const found = returned.get(item.storeProductId);
|
|
210
|
+
if (!found) {
|
|
211
|
+
// The store knows the id but will not sell it here: unapproved, or
|
|
212
|
+
// not available in this country. Never conflated with a network
|
|
213
|
+
// failure (FR-027).
|
|
214
|
+
missing.push({
|
|
215
|
+
productKey: item.productKey,
|
|
216
|
+
reason: 'not_available_from_store',
|
|
217
|
+
});
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const product = normalize(found, item.productKey, item.productType);
|
|
221
|
+
cache.set(item.productKey, { product, cachedAt: deps.now() });
|
|
222
|
+
resolvedProducts.push(product);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
} catch (error) {
|
|
226
|
+
const paymentError =
|
|
227
|
+
error instanceof PaymentError
|
|
228
|
+
? error
|
|
229
|
+
: new PaymentError({
|
|
230
|
+
code: 'PAYMENT_NETWORK_ERROR',
|
|
231
|
+
message: 'Could not reach the store to query products',
|
|
232
|
+
operation: 'getProducts',
|
|
233
|
+
retryable: true,
|
|
234
|
+
});
|
|
235
|
+
deps.emit({
|
|
236
|
+
type: 'internal_error',
|
|
237
|
+
code: paymentError.code,
|
|
238
|
+
operation: 'getProducts',
|
|
239
|
+
});
|
|
240
|
+
return { ok: false, error: paymentError };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
deps.emit({
|
|
244
|
+
type: 'products_queried',
|
|
245
|
+
productKeys,
|
|
246
|
+
found: resolvedProducts.length,
|
|
247
|
+
missing: missing.map(m => m.productKey),
|
|
248
|
+
fromCache: false,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
return { ok: true, products: resolvedProducts, missing, fromCache: false };
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
get: productKeys => query(productKeys, true),
|
|
256
|
+
refresh: productKeys => query(productKeys, false),
|
|
257
|
+
invalidate: () => cache.clear(),
|
|
258
|
+
};
|
|
259
|
+
}
|