@chipmobilesdk/rn-payment 0.4.0 → 0.5.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 +482 -334
- package/README.md +925 -897
- package/package.json +52 -54
- package/skills/sdk-rn-payment/SKILL.md +175 -174
- package/src/adapter/createStoreProvider.ts +116 -118
- package/src/adapter/iap-adapter.ts +627 -660
- package/src/adapter/port.ts +209 -215
- package/src/config/types.ts +298 -310
- package/src/config/validate.ts +224 -232
- package/src/core/mutex.ts +25 -128
- package/src/core/retry.ts +95 -133
- package/src/core/timers.ts +272 -267
- package/src/diagnostics/errors.ts +124 -117
- package/src/diagnostics/events.ts +282 -266
- package/src/diagnostics/messageKeys.ts +93 -95
- package/src/engine/engine.ts +921 -999
- package/src/engine/finalize.ts +343 -346
- package/src/engine/foreground.ts +29 -0
- package/src/engine/products.ts +259 -259
- package/src/engine/purchase.ts +589 -614
- package/src/engine/subscriptions.ts +146 -157
- package/src/entitlements/aggregate.ts +261 -242
- package/src/entitlements/mapping.ts +67 -67
- package/src/entitlements/policy.ts +121 -129
- package/src/index.ts +5 -9
- package/src/persistence/mmkv-store.ts +158 -158
- package/src/persistence/snapshot.ts +220 -247
- package/src/react/PaymentProvider.tsx +90 -79
- package/src/react/hooks.ts +236 -274
- package/src/testing/conformance.ts +169 -0
- package/src/testing/fakeStore.ts +360 -393
- package/src/testing/index.ts +19 -10
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in foreground sync over rn-core's shared lifecycle port (PERF-07, FR-059).
|
|
3
|
+
*
|
|
4
|
+
* `handleForeground()` stays the app's to call: the engine does not decide what a
|
|
5
|
+
* foreground return is. An app that is happy with "back from the background" calls
|
|
6
|
+
* this once instead of wiring `AppState` itself, and shares the one native
|
|
7
|
+
* subscription every package uses. A trip through `inactive` alone (a biometric
|
|
8
|
+
* prompt, iOS Control Center) is not a return. On Android the Play purchase sheet
|
|
9
|
+
* does send the app to the background, so a purchase is followed by a sync; the
|
|
10
|
+
* engine's foreground rate limit keeps that to one request per interval.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
appLifecycle,
|
|
14
|
+
onForeground,
|
|
15
|
+
type AppLifecyclePort,
|
|
16
|
+
type Unsubscribe,
|
|
17
|
+
} from '@chipmobilesdk/rn-core';
|
|
18
|
+
|
|
19
|
+
import type { PaymentEngine } from './engine';
|
|
20
|
+
|
|
21
|
+
export function syncOnForeground(
|
|
22
|
+
engine: Pick<PaymentEngine, 'handleForeground'>,
|
|
23
|
+
lifecycle: AppLifecyclePort = appLifecycle(),
|
|
24
|
+
): Unsubscribe {
|
|
25
|
+
return onForeground(() => {
|
|
26
|
+
// Sync reports its own failures through status and diagnostics.
|
|
27
|
+
engine.handleForeground().catch(() => undefined);
|
|
28
|
+
}, lifecycle);
|
|
29
|
+
}
|
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
|
+
}
|