@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
|
@@ -1,242 +1,261 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Entitlement aggregation — the package's primary output.
|
|
3
|
-
*
|
|
4
|
-
* Everything else exists to make this one answer correct. The most damaging
|
|
5
|
-
* defect class in a payment SDK lives here: collapsing "not yet synced" into "not
|
|
6
|
-
* entitled" makes a paying user watch ads flash on cold start or see a purchased
|
|
7
|
-
* feature sit locked.
|
|
8
|
-
*
|
|
9
|
-
* Two rules do most of the work:
|
|
10
|
-
*
|
|
11
|
-
* - **A pending purchase is never a source** (FR-037). Enforced here, in
|
|
12
|
-
* aggregation, rather than at each call site, so no future code path can grant
|
|
13
|
-
* on money that has not arrived.
|
|
14
|
-
* - **Uncertainty resolves to `unknown`, never `not_entitled`** (FR-047,
|
|
15
|
-
* FR-055a). Never synced, stale, or scope-changed all mean "we do not know",
|
|
16
|
-
* and the package says so rather than asserting the user did not pay.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import
|
|
20
|
-
import type {
|
|
21
|
-
import type {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
purchase
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
export
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Entitlement aggregation — the package's primary output.
|
|
3
|
+
*
|
|
4
|
+
* Everything else exists to make this one answer correct. The most damaging
|
|
5
|
+
* defect class in a payment SDK lives here: collapsing "not yet synced" into "not
|
|
6
|
+
* entitled" makes a paying user watch ads flash on cold start or see a purchased
|
|
7
|
+
* feature sit locked.
|
|
8
|
+
*
|
|
9
|
+
* Two rules do most of the work:
|
|
10
|
+
*
|
|
11
|
+
* - **A pending purchase is never a source** (FR-037). Enforced here, in
|
|
12
|
+
* aggregation, rather than at each call site, so no future code path can grant
|
|
13
|
+
* on money that has not arrived.
|
|
14
|
+
* - **Uncertainty resolves to `unknown`, never `not_entitled`** (FR-047,
|
|
15
|
+
* FR-055a). Never synced, stale, or scope-changed all mean "we do not know",
|
|
16
|
+
* and the package says so rather than asserting the user did not pay.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { shallowEqual } from '@chipmobilesdk/rn-core';
|
|
20
|
+
import type { ResolvedConfig } from '../config/validate';
|
|
21
|
+
import type { EntitlementKey, ProductKey } from '../config/types';
|
|
22
|
+
import type { PersistedPurchase, PersistedSnapshot } from '../persistence/snapshot';
|
|
23
|
+
import { entitlementsFor } from './mapping';
|
|
24
|
+
import { deriveRenewalState, grantsUnderPolicy, hasExpired, type RenewalState } from './policy';
|
|
25
|
+
|
|
26
|
+
export type EntitlementStatus = 'unknown' | 'entitled' | 'not_entitled';
|
|
27
|
+
|
|
28
|
+
export interface SubscriptionDetail {
|
|
29
|
+
expiresAt?: number;
|
|
30
|
+
willAutoRenew: boolean;
|
|
31
|
+
isCancelledButValid: boolean;
|
|
32
|
+
renewalState: RenewalState;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface EntitlementState {
|
|
36
|
+
key: EntitlementKey;
|
|
37
|
+
status: EntitlementStatus;
|
|
38
|
+
/** The app's own product keys currently granting this. Never raw transactions (FR-051). */
|
|
39
|
+
sources: ProductKey[];
|
|
40
|
+
subscription?: SubscriptionDetail;
|
|
41
|
+
lastSyncedAt?: number;
|
|
42
|
+
/** True once the snapshot is older than `staleAfterMs`; status is then `unknown`. */
|
|
43
|
+
isStale: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type EntitlementMap = Record<EntitlementKey, EntitlementState>;
|
|
47
|
+
|
|
48
|
+
export interface AggregateInput {
|
|
49
|
+
config: ResolvedConfig;
|
|
50
|
+
snapshot: PersistedSnapshot;
|
|
51
|
+
/** False until the first successful sync of this session or a hydrated one. */
|
|
52
|
+
hasSynced: boolean;
|
|
53
|
+
reportsGraceAndHoldDistinctly: boolean;
|
|
54
|
+
now: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A purchase that currently confers its entitlements. */
|
|
58
|
+
function isValidSource(purchase: PersistedPurchase, input: AggregateInput): boolean {
|
|
59
|
+
// Pending money is not money. No configuration and no code path may grant here.
|
|
60
|
+
if (purchase.state === 'pending') {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
if (purchase.state === 'revoked' || purchase.state === 'consumed') {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
// A backend that said no is final (FR-071). A queued re-validation still
|
|
67
|
+
// grants, which is the documented `grant_and_queue` fallback (FR-072).
|
|
68
|
+
if (purchase.validationState === 'rejected') {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (purchase.productType === 'subscription') {
|
|
73
|
+
if (hasExpired(purchase.expiresAt, input.now)) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const renewalState = deriveRenewalState({
|
|
77
|
+
expiresAt: purchase.expiresAt,
|
|
78
|
+
willAutoRenew: purchase.willAutoRenew,
|
|
79
|
+
reportsGraceAndHoldDistinctly: input.reportsGraceAndHoldDistinctly,
|
|
80
|
+
});
|
|
81
|
+
return grantsUnderPolicy(renewalState, {
|
|
82
|
+
gracePolicy: input.config.gracePolicy,
|
|
83
|
+
holdPolicy: input.config.holdPolicy,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A consumable that has not been consumed yet is owned, but it must never
|
|
88
|
+
// become a permanent entitlement — consumption flips it to `consumed` above.
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function subscriptionDetailFor(
|
|
93
|
+
purchases: PersistedPurchase[],
|
|
94
|
+
input: AggregateInput,
|
|
95
|
+
): SubscriptionDetail | undefined {
|
|
96
|
+
const subs = purchases.filter(p => p.productType === 'subscription');
|
|
97
|
+
if (subs.length === 0) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Where several subscriptions grant one entitlement, the one running longest
|
|
102
|
+
// is what the user experiences, so report that one.
|
|
103
|
+
const furthest = subs.reduce((best, candidate) => {
|
|
104
|
+
const bestAt = best.expiresAt ?? Number.MAX_SAFE_INTEGER;
|
|
105
|
+
const candidateAt = candidate.expiresAt ?? Number.MAX_SAFE_INTEGER;
|
|
106
|
+
return candidateAt > bestAt ? candidate : best;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const renewalState = deriveRenewalState({
|
|
110
|
+
expiresAt: furthest.expiresAt,
|
|
111
|
+
willAutoRenew: furthest.willAutoRenew,
|
|
112
|
+
reportsGraceAndHoldDistinctly: input.reportsGraceAndHoldDistinctly,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
expiresAt: furthest.expiresAt,
|
|
117
|
+
willAutoRenew: furthest.willAutoRenew === true,
|
|
118
|
+
isCancelledButValid: renewalState === 'cancelled_valid',
|
|
119
|
+
renewalState,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Computes the full entitlement map.
|
|
125
|
+
*
|
|
126
|
+
* Pure and synchronous by design: it runs on render paths, so it must never
|
|
127
|
+
* touch I/O (PERF-001). The engine calls it after any state change and caches the
|
|
128
|
+
* result.
|
|
129
|
+
*/
|
|
130
|
+
/**
|
|
131
|
+
* How far the snapshot can be trusted right now.
|
|
132
|
+
*
|
|
133
|
+
* Shared by entitlements and the pending set (FR-103) so the two can never
|
|
134
|
+
* disagree about whether the package knows anything — a paywall that showed
|
|
135
|
+
* "not entitled" next to "nothing pending" from one stale snapshot would offer
|
|
136
|
+
* Buy to a user whose payment is in flight.
|
|
137
|
+
*/
|
|
138
|
+
export function snapshotCertainty(input: {
|
|
139
|
+
config: Pick<ResolvedConfig, 'staleAfterMs'>;
|
|
140
|
+
snapshot: Pick<PersistedSnapshot, 'lastSyncedAt'>;
|
|
141
|
+
hasSynced: boolean;
|
|
142
|
+
now: number;
|
|
143
|
+
}): { isStale: boolean; undetermined: boolean } {
|
|
144
|
+
const { config, snapshot, hasSynced, now } = input;
|
|
145
|
+
const isStale =
|
|
146
|
+
snapshot.lastSyncedAt !== undefined && now - snapshot.lastSyncedAt > config.staleAfterMs;
|
|
147
|
+
|
|
148
|
+
// Both roads lead to `unknown`, never to a confident "no". Never having synced
|
|
149
|
+
// and having synced too long ago are the same epistemic state: we cannot
|
|
150
|
+
// currently support a claim either way.
|
|
151
|
+
return { isStale, undetermined: !hasSynced || isStale };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function aggregate(input: AggregateInput): EntitlementMap {
|
|
155
|
+
const { config, snapshot } = input;
|
|
156
|
+
const { isStale, undetermined } = snapshotCertainty(input);
|
|
157
|
+
|
|
158
|
+
const valid = snapshot.purchases.filter(p => isValidSource(p, input));
|
|
159
|
+
|
|
160
|
+
const result: EntitlementMap = {};
|
|
161
|
+
for (const key of config.entitlementKeys) {
|
|
162
|
+
const granting = valid.filter(p => entitlementsFor(config, p.productKey).includes(key));
|
|
163
|
+
const sources = [...new Set(granting.map(p => p.productKey))];
|
|
164
|
+
|
|
165
|
+
result[key] = {
|
|
166
|
+
key,
|
|
167
|
+
// An entitlement stays valid while at least one source remains (FR-052).
|
|
168
|
+
status: undetermined ? 'unknown' : sources.length > 0 ? 'entitled' : 'not_entitled',
|
|
169
|
+
sources,
|
|
170
|
+
subscription: subscriptionDetailFor(granting, input),
|
|
171
|
+
lastSyncedAt: snapshot.lastSyncedAt,
|
|
172
|
+
isStale,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The initial map, before anything is known. Every key starts at `unknown` (FR-047). */
|
|
180
|
+
export function initialEntitlements(config: ResolvedConfig): EntitlementMap {
|
|
181
|
+
const result: EntitlementMap = {};
|
|
182
|
+
for (const key of config.entitlementKeys) {
|
|
183
|
+
result[key] = {
|
|
184
|
+
key,
|
|
185
|
+
status: 'unknown',
|
|
186
|
+
sources: [],
|
|
187
|
+
lastSyncedAt: undefined,
|
|
188
|
+
isStale: false,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface EntitlementChange {
|
|
195
|
+
key: EntitlementKey;
|
|
196
|
+
previous: EntitlementStatus;
|
|
197
|
+
next: EntitlementStatus;
|
|
198
|
+
sources: ProductKey[];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Diffs two maps to produce change signals.
|
|
203
|
+
*
|
|
204
|
+
* Emitting from a diff rather than from grant call sites is what makes the
|
|
205
|
+
* exactly-once guarantees structural (FR-079): a successful subscription renewal
|
|
206
|
+
* changes no status, so it produces no event and cannot double-signal (FR-068);
|
|
207
|
+
* and an entitlement with three sources fires once on transition, not once per
|
|
208
|
+
* source.
|
|
209
|
+
*/
|
|
210
|
+
export function diffEntitlements(
|
|
211
|
+
previous: EntitlementMap,
|
|
212
|
+
next: EntitlementMap,
|
|
213
|
+
): EntitlementChange[] {
|
|
214
|
+
const changes: EntitlementChange[] = [];
|
|
215
|
+
for (const key of Object.keys(next)) {
|
|
216
|
+
const before = previous[key]?.status ?? 'unknown';
|
|
217
|
+
const after = next[key].status;
|
|
218
|
+
if (before !== after) {
|
|
219
|
+
changes.push({
|
|
220
|
+
key,
|
|
221
|
+
previous: before,
|
|
222
|
+
next: after,
|
|
223
|
+
sources: next[key].sources,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return changes;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function sameEntitlement(a: EntitlementState, b: EntitlementState): boolean {
|
|
231
|
+
return (
|
|
232
|
+
a.status === b.status &&
|
|
233
|
+
a.isStale === b.isStale &&
|
|
234
|
+
a.lastSyncedAt === b.lastSyncedAt &&
|
|
235
|
+
a.sources.length === b.sources.length &&
|
|
236
|
+
a.sources.every((source, index) => source === b.sources[index]) &&
|
|
237
|
+
shallowEqual(a.subscription, b.subscription)
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* `next`, with every entitlement that did not change replaced by the object it
|
|
243
|
+
* replaces, and `previous` itself when nothing changed (PERF-03, PAY-08). A hook
|
|
244
|
+
* selecting one entitlement then sees the same object and does not re-render.
|
|
245
|
+
*/
|
|
246
|
+
export function shareUnchanged(previous: EntitlementMap, next: EntitlementMap): EntitlementMap {
|
|
247
|
+
const keys = Object.keys(next);
|
|
248
|
+
let changed = keys.length !== Object.keys(previous).length;
|
|
249
|
+
const shared: EntitlementMap = {};
|
|
250
|
+
for (const key of keys) {
|
|
251
|
+
const before = previous[key];
|
|
252
|
+
const after = next[key]!;
|
|
253
|
+
if (before && sameEntitlement(before, after)) {
|
|
254
|
+
shared[key] = before;
|
|
255
|
+
} else {
|
|
256
|
+
shared[key] = after;
|
|
257
|
+
changed = true;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return changed ? shared : previous;
|
|
261
|
+
}
|
|
@@ -1,67 +1,67 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Product ↔ entitlement indexing.
|
|
3
|
-
*
|
|
4
|
-
* The relation is many-to-many (FR-010): monthly, yearly, and lifetime products
|
|
5
|
-
* may all grant `no_ads`, and one bundle product may grant several entitlements.
|
|
6
|
-
*
|
|
7
|
-
* Both directions are indexed once at configure time. The reverse direction is
|
|
8
|
-
* what makes two requirements cheap on hot paths: naming which product currently
|
|
9
|
-
* grants an entitlement (FR-051), and keeping an entitlement valid while *any*
|
|
10
|
-
* source remains (FR-052).
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import type { ResolvedConfig } from '../config/validate';
|
|
14
|
-
import type { EntitlementKey, ProductKey } from '../config/types';
|
|
15
|
-
|
|
16
|
-
/** Entitlements a product confers. Empty for an unknown key rather than throwing. */
|
|
17
|
-
export function entitlementsFor(
|
|
18
|
-
config: ResolvedConfig,
|
|
19
|
-
productKey: ProductKey,
|
|
20
|
-
): readonly EntitlementKey[] {
|
|
21
|
-
return config.grants.get(productKey) ?? [];
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Products that could grant an entitlement, whether or not any is currently valid. */
|
|
25
|
-
export function sourcesFor(
|
|
26
|
-
config: ResolvedConfig,
|
|
27
|
-
entitlementKey: EntitlementKey,
|
|
28
|
-
): readonly ProductKey[] {
|
|
29
|
-
return config.sources.get(entitlementKey) ?? [];
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Translates a store product id back to the app's own product key.
|
|
34
|
-
*
|
|
35
|
-
* Needed because store callbacks and `queryOwned` speak in store ids, while
|
|
36
|
-
* everything above the adapter speaks in product keys (FR-003). Returns
|
|
37
|
-
* `undefined` for an id absent from the catalog — which happens legitimately when
|
|
38
|
-
* a user owns something from a build whose catalog has since changed, so it must
|
|
39
|
-
* be handled rather than treated as impossible.
|
|
40
|
-
*/
|
|
41
|
-
export function productKeyForStoreId(
|
|
42
|
-
config: ResolvedConfig,
|
|
43
|
-
storeProductId: string,
|
|
44
|
-
): ProductKey | undefined {
|
|
45
|
-
return config.byStoreId.get(storeProductId);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function storeIdForProductKey(
|
|
49
|
-
config: ResolvedConfig,
|
|
50
|
-
productKey: ProductKey,
|
|
51
|
-
): string | undefined {
|
|
52
|
-
return config.entries.get(productKey)?.storeProductIds[config.platform];
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Which entitlements a set of granting products covers, de-duplicated. */
|
|
56
|
-
export function entitlementsCoveredBy(
|
|
57
|
-
config: ResolvedConfig,
|
|
58
|
-
productKeys: Iterable<ProductKey>,
|
|
59
|
-
): Set<EntitlementKey> {
|
|
60
|
-
const covered = new Set<EntitlementKey>();
|
|
61
|
-
for (const productKey of productKeys) {
|
|
62
|
-
for (const entitlement of entitlementsFor(config, productKey)) {
|
|
63
|
-
covered.add(entitlement);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return covered;
|
|
67
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Product ↔ entitlement indexing.
|
|
3
|
+
*
|
|
4
|
+
* The relation is many-to-many (FR-010): monthly, yearly, and lifetime products
|
|
5
|
+
* may all grant `no_ads`, and one bundle product may grant several entitlements.
|
|
6
|
+
*
|
|
7
|
+
* Both directions are indexed once at configure time. The reverse direction is
|
|
8
|
+
* what makes two requirements cheap on hot paths: naming which product currently
|
|
9
|
+
* grants an entitlement (FR-051), and keeping an entitlement valid while *any*
|
|
10
|
+
* source remains (FR-052).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ResolvedConfig } from '../config/validate';
|
|
14
|
+
import type { EntitlementKey, ProductKey } from '../config/types';
|
|
15
|
+
|
|
16
|
+
/** Entitlements a product confers. Empty for an unknown key rather than throwing. */
|
|
17
|
+
export function entitlementsFor(
|
|
18
|
+
config: ResolvedConfig,
|
|
19
|
+
productKey: ProductKey,
|
|
20
|
+
): readonly EntitlementKey[] {
|
|
21
|
+
return config.grants.get(productKey) ?? [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Products that could grant an entitlement, whether or not any is currently valid. */
|
|
25
|
+
export function sourcesFor(
|
|
26
|
+
config: ResolvedConfig,
|
|
27
|
+
entitlementKey: EntitlementKey,
|
|
28
|
+
): readonly ProductKey[] {
|
|
29
|
+
return config.sources.get(entitlementKey) ?? [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Translates a store product id back to the app's own product key.
|
|
34
|
+
*
|
|
35
|
+
* Needed because store callbacks and `queryOwned` speak in store ids, while
|
|
36
|
+
* everything above the adapter speaks in product keys (FR-003). Returns
|
|
37
|
+
* `undefined` for an id absent from the catalog — which happens legitimately when
|
|
38
|
+
* a user owns something from a build whose catalog has since changed, so it must
|
|
39
|
+
* be handled rather than treated as impossible.
|
|
40
|
+
*/
|
|
41
|
+
export function productKeyForStoreId(
|
|
42
|
+
config: ResolvedConfig,
|
|
43
|
+
storeProductId: string,
|
|
44
|
+
): ProductKey | undefined {
|
|
45
|
+
return config.byStoreId.get(storeProductId);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function storeIdForProductKey(
|
|
49
|
+
config: ResolvedConfig,
|
|
50
|
+
productKey: ProductKey,
|
|
51
|
+
): string | undefined {
|
|
52
|
+
return config.entries.get(productKey)?.storeProductIds[config.platform];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Which entitlements a set of granting products covers, de-duplicated. */
|
|
56
|
+
export function entitlementsCoveredBy(
|
|
57
|
+
config: ResolvedConfig,
|
|
58
|
+
productKeys: Iterable<ProductKey>,
|
|
59
|
+
): Set<EntitlementKey> {
|
|
60
|
+
const covered = new Set<EntitlementKey>();
|
|
61
|
+
for (const productKey of productKeys) {
|
|
62
|
+
for (const entitlement of entitlementsFor(config, productKey)) {
|
|
63
|
+
covered.add(entitlement);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return covered;
|
|
67
|
+
}
|