@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.
@@ -0,0 +1,298 @@
1
+ /**
2
+ * The finalization queue — the package's hardest invariant (FR-041, FR-042).
3
+ *
4
+ * Google auto-refunds and revokes a purchase that is not acknowledged within
5
+ * **three days**. So finalization cannot be a step the app might forget, and it
6
+ * cannot live only in memory: the failure case FR-042 names is the app being
7
+ * force-killed in the instant between payment and acknowledgement.
8
+ *
9
+ * The ordering that makes this work is: **write the intent, then call the store.**
10
+ * A crash before the write means the purchase is still owned and the next sync
11
+ * re-queues it from `queryOwned`. A crash after the write means the queue has a
12
+ * record and the next startup drains it. There is no window where a paid
13
+ * transaction is forgotten.
14
+ *
15
+ * Consumption stays app-triggered (FR-043), because only the app knows whether
16
+ * the item was actually banked in its own data.
17
+ */
18
+
19
+ import type {
20
+ FinalizeResult,
21
+ ProductKey,
22
+ TransactionRef,
23
+ UnfinalizedSummary,
24
+ } from '../config/types';
25
+ import type { ProviderPort } from '../adapter/port';
26
+ import type { ResolvedRetry } from '../core/timers';
27
+ import { backoffDelayMs } from '../core/retry';
28
+ import {
29
+ pendingFinalizations,
30
+ type PersistedPurchase,
31
+ type PersistedSnapshot,
32
+ } from '../persistence/snapshot';
33
+ import type { PaymentEventInput } from '../diagnostics/events';
34
+
35
+ export interface FinalizerDeps {
36
+ provider: ProviderPort;
37
+ retry: ResolvedRetry;
38
+ snapshot: () => PersistedSnapshot;
39
+ commit: (next: PersistedSnapshot) => void;
40
+ emit: (event: PaymentEventInput) => void;
41
+ now: () => number;
42
+ disposed: () => boolean;
43
+ /** Injected so a test can run the retry schedule without waiting it out. */
44
+ setTimeoutFn?: (fn: () => void, ms: number) => FinalizeTimerHandle;
45
+ clearTimeoutFn?: (handle: FinalizeTimerHandle) => void;
46
+ }
47
+
48
+ /** A Node `Timeout` and a browser numeric id are both valid here. */
49
+ type FinalizeTimerHandle = ReturnType<typeof setTimeout> | number;
50
+
51
+ export interface Finalizer {
52
+ /** Records the intent to finalize, then attempts it. Safe to call repeatedly. */
53
+ enqueue(transactionId: string): Promise<FinalizeResult>;
54
+ /** Drains every outstanding finalization. Runs at startup and on foreground. */
55
+ drain(): Promise<void>;
56
+ /** App-triggered consumption of a consumable (FR-043). */
57
+ consume(ref: TransactionRef): Promise<FinalizeResult>;
58
+ /** Paid-but-unsettled transactions, so the condition is observable (FR-045). */
59
+ unfinalized(): UnfinalizedSummary[];
60
+ /** Cancels pending retries. Idempotent, called from cleanup. */
61
+ dispose(): void;
62
+ }
63
+
64
+ export function createFinalizer(deps: FinalizerDeps): Finalizer {
65
+ const setTimeoutFn = deps.setTimeoutFn ?? setTimeout;
66
+ const clearTimeoutFn = deps.clearTimeoutFn ?? clearTimeout;
67
+
68
+ /** One timer per transaction, so two transactions retry independently. */
69
+ const timers = new Map<string, FinalizeTimerHandle>();
70
+ /** Guards against two drains racing the same transaction. */
71
+ const inFlight = new Set<string>();
72
+
73
+ const find = (transactionId: string): PersistedPurchase | undefined =>
74
+ deps.snapshot().purchases.find(p => p.transactionId === transactionId);
75
+
76
+ const patch = (
77
+ transactionId: string,
78
+ changes: Partial<PersistedPurchase>,
79
+ ): void => {
80
+ const snapshot = deps.snapshot();
81
+ deps.commit({
82
+ ...snapshot,
83
+ purchases: snapshot.purchases.map(p =>
84
+ p.transactionId === transactionId ? { ...p, ...changes } : p,
85
+ ),
86
+ });
87
+ };
88
+
89
+ const clearTimer = (transactionId: string): void => {
90
+ const timer = timers.get(transactionId);
91
+ if (timer !== undefined) {
92
+ clearTimeoutFn(timer);
93
+ timers.delete(transactionId);
94
+ }
95
+ };
96
+
97
+ /**
98
+ * Attempts one finalize call.
99
+ *
100
+ * A consumable is **not** acknowledged here: it waits for the app's explicit
101
+ * `consume`. Acknowledging it now would tell the store the entitlement was
102
+ * delivered before the app has recorded the item, and a crash in between would
103
+ * lose a paid item with no way to recover it.
104
+ */
105
+ const attempt = async (transactionId: string): Promise<FinalizeResult> => {
106
+ if (deps.disposed()) {
107
+ return { kind: 'failed', code: 'PAYMENT_NOT_CONFIGURED' };
108
+ }
109
+
110
+ const record = find(transactionId);
111
+ if (!record) {
112
+ return { kind: 'unknown_transaction' };
113
+ }
114
+ if (record.finalizeState === 'done') {
115
+ // Idempotent by design: calling twice is a success, not an error (FR-044).
116
+ return { kind: 'already_finalized' };
117
+ }
118
+ if (record.productType === 'one_time_consumable') {
119
+ // The app owns this moment. Nothing to do until it calls consume().
120
+ return { kind: 'already_finalized' };
121
+ }
122
+ if (inFlight.has(transactionId)) {
123
+ return { kind: 'retry_scheduled', attempts: record.attempts };
124
+ }
125
+
126
+ inFlight.add(transactionId);
127
+ patch(transactionId, { finalizeState: 'in_flight' });
128
+
129
+ let failed = false;
130
+ try {
131
+ await deps.provider.acknowledge(record.token);
132
+ } catch {
133
+ failed = true;
134
+ } finally {
135
+ // Released before scheduling the retry, deliberately. The guard exists to
136
+ // stop two concurrent store calls for one transaction, nothing more — if
137
+ // it were still held here, the next scheduled attempt would be refused by
138
+ // our own lock and the retry schedule would silently stall after one try.
139
+ inFlight.delete(transactionId);
140
+ }
141
+
142
+ const attempts = record.attempts + 1;
143
+
144
+ if (!failed) {
145
+ clearTimer(transactionId);
146
+ patch(transactionId, {
147
+ finalizeState: 'done',
148
+ state: 'purchased',
149
+ attempts,
150
+ });
151
+ deps.emit({
152
+ type: 'transaction_finalized',
153
+ productKey: record.productKey,
154
+ attempts,
155
+ });
156
+ return { kind: 'finalized' };
157
+ }
158
+
159
+ patch(transactionId, { finalizeState: 'failed', attempts });
160
+ return scheduleRetry(transactionId, record.productKey, attempts);
161
+ };
162
+
163
+ /**
164
+ * Schedules the next attempt inside the per-session budget.
165
+ *
166
+ * Exhausting the budget is **not** giving up. The record stays `failed`, which
167
+ * `pendingFinalizations` still returns, so the next startup or foreground drain
168
+ * picks it up again. That is what keeps the package inside Play's three-day
169
+ * window without hammering a store that is currently refusing.
170
+ */
171
+ const scheduleRetry = (
172
+ transactionId: string,
173
+ productKey: ProductKey,
174
+ attempts: number,
175
+ ): FinalizeResult => {
176
+ if (deps.disposed()) {
177
+ return { kind: 'failed', code: 'PAYMENT_FINALIZE_FAILED' };
178
+ }
179
+ if (attempts >= deps.retry.finalize.maxAttempts) {
180
+ // Left queued deliberately — resumed next session, never abandoned.
181
+ return { kind: 'failed', code: 'PAYMENT_FINALIZE_FAILED' };
182
+ }
183
+
184
+ const delayMs = backoffDelayMs(attempts + 1, deps.retry.finalize);
185
+ clearTimer(transactionId);
186
+ timers.set(
187
+ transactionId,
188
+ setTimeoutFn(() => {
189
+ timers.delete(transactionId);
190
+ attempt(transactionId).catch(() => undefined);
191
+ }, delayMs),
192
+ );
193
+
194
+ deps.emit({
195
+ type: 'finalize_retry_scheduled',
196
+ productKey,
197
+ attempt: attempts + 1,
198
+ delayMs,
199
+ });
200
+ return { kind: 'retry_scheduled', attempts };
201
+ };
202
+
203
+ return {
204
+ async enqueue(transactionId: string): Promise<FinalizeResult> {
205
+ const record = find(transactionId);
206
+ if (!record) {
207
+ return { kind: 'unknown_transaction' };
208
+ }
209
+ if (record.finalizeState === 'done') {
210
+ return { kind: 'already_finalized' };
211
+ }
212
+
213
+ // The write happens BEFORE the store call. This single ordering is what
214
+ // makes a kill between payment and acknowledgement recoverable.
215
+ if (record.finalizeState !== 'in_flight') {
216
+ patch(transactionId, { finalizeState: 'queued' });
217
+ }
218
+ return attempt(transactionId);
219
+ },
220
+
221
+ async drain(): Promise<void> {
222
+ // Runs regardless of whether a purchase happened this session: the record
223
+ // it is looking for was probably written by a session that died.
224
+ for (const record of pendingFinalizations(deps.snapshot())) {
225
+ if (deps.disposed()) {
226
+ return;
227
+ }
228
+ await attempt(record.transactionId).catch(() => undefined);
229
+ }
230
+ },
231
+
232
+ async consume(ref: TransactionRef): Promise<FinalizeResult> {
233
+ const record = find(ref.transactionId);
234
+ if (!record) {
235
+ return { kind: 'unknown_transaction' };
236
+ }
237
+ if (record.state === 'consumed') {
238
+ // Consuming twice must grant the item once (FR-044).
239
+ return { kind: 'already_finalized' };
240
+ }
241
+ if (inFlight.has(ref.transactionId)) {
242
+ // Concurrent consume calls collapse to one, for the same reason.
243
+ return { kind: 'already_finalized' };
244
+ }
245
+
246
+ inFlight.add(ref.transactionId);
247
+ patch(ref.transactionId, { finalizeState: 'in_flight' });
248
+
249
+ let failed = false;
250
+ try {
251
+ await deps.provider.consume(record.token);
252
+ } catch {
253
+ failed = true;
254
+ } finally {
255
+ // Released before scheduling, for the same reason as in `attempt`.
256
+ inFlight.delete(ref.transactionId);
257
+ }
258
+
259
+ const attempts = record.attempts + 1;
260
+
261
+ if (failed) {
262
+ patch(ref.transactionId, { finalizeState: 'failed', attempts });
263
+ return scheduleRetry(ref.transactionId, record.productKey, attempts);
264
+ }
265
+
266
+ clearTimer(ref.transactionId);
267
+ // `consumed` is what stops it being an entitlement source, so a
268
+ // consumable never becomes a permanent unlock.
269
+ patch(ref.transactionId, {
270
+ state: 'consumed',
271
+ finalizeState: 'done',
272
+ attempts,
273
+ });
274
+ deps.emit({
275
+ type: 'transaction_consumed',
276
+ productKey: record.productKey,
277
+ });
278
+ return { kind: 'finalized' };
279
+ },
280
+
281
+ unfinalized(): UnfinalizedSummary[] {
282
+ return pendingFinalizations(deps.snapshot()).map(p => ({
283
+ productKey: p.productKey,
284
+ transactionId: p.transactionId,
285
+ attempts: p.attempts,
286
+ purchasedAt: p.purchasedAt,
287
+ }));
288
+ },
289
+
290
+ dispose(): void {
291
+ for (const timer of timers.values()) {
292
+ clearTimeoutFn(timer);
293
+ }
294
+ timers.clear();
295
+ inFlight.clear();
296
+ },
297
+ };
298
+ }
@@ -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
+ }
@@ -0,0 +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
+ }