@pithy-sh/ui-react-native 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/generatedConfig.d.ts +195 -0
- package/dist/generatedConfig.d.ts.map +1 -0
- package/dist/generatedConfig.js +323 -0
- package/dist/templates.d.ts +48 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +85 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +10 -0
- package/package.json +83 -4
- package/src/generatedConfig.ts +419 -0
- package/src/templates.ts +92 -0
- package/src/version.ts +13 -0
- package/templates/app/[...pithy].tsx +7 -0
- package/templates/app/_layout.tsx +31 -0
- package/templates/app/index.tsx +4 -0
- package/templates/app.config.ts +56 -0
- package/templates/metro-proxy.cjs +289 -0
- package/templates/metro.config.js +6 -0
- package/templates/src/payments-store.native.ts +20 -0
- package/templates/src/payments-store.ts +16 -0
- package/templates/src/payments.tsx +769 -0
- package/templates/src/pithy-config.generated.ts +99 -0
- package/templates/src/pithy-config.ts +169 -0
- package/templates/src/pithy-locale.tsx +90 -0
- package/templates/src/platform.ts +111 -0
- package/templates/src/route-modules.ts +76 -0
- package/templates/src/router.tsx +387 -0
- package/templates/src/routes/app/home.bare.tsx +75 -0
- package/templates/src/routes/app/home.tsx +55 -0
- package/templates/src/routes/pithy/account-deleted.tsx +69 -0
- package/templates/src/routes/pithy/account-suspended.tsx +70 -0
- package/templates/src/routes/pithy/agent-consent.tsx +260 -0
- package/templates/src/routes/pithy/callback.tsx +74 -0
- package/templates/src/routes/pithy/choose-organization.tsx +230 -0
- package/templates/src/routes/pithy/connected-applications.tsx +153 -0
- package/templates/src/routes/pithy/consent.tsx +453 -0
- package/templates/src/routes/pithy/invitation.tsx +239 -0
- package/templates/src/routes/pithy/organization-suspended.tsx +51 -0
- package/templates/src/routes/pithy/otp.tsx +223 -0
- package/templates/src/routes/pithy/paywall.tsx +253 -0
- package/templates/src/routes/pithy/pricing.tsx +317 -0
- package/templates/src/routes/pithy/sign-in.tsx +674 -0
- package/templates/src/routes/pithy/subscription.tsx +240 -0
- package/templates/src/session.tsx +349 -0
- package/templates/src/theme.ts +100 -0
- package/templates/src/transport.ts +290 -0
- package/templates/src/turnstile.tsx +117 -0
- package/templates/src/useAppFocus.ts +20 -0
- package/templates/src/useStoreTransactions.ts +16 -0
- package/templates/src/useTurnstileWeb.ts +104 -0
- package/templates/tsconfig.json +10 -0
- package/README.md +0 -3
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
import { getSession } from "@pithy-sh/auth/src/client/api";
|
|
2
|
+
import type { Translator } from "@pithy-sh/core/src/i18n/translator";
|
|
3
|
+
import {
|
|
4
|
+
type EntitlementView,
|
|
5
|
+
getEntitlements,
|
|
6
|
+
PAYMENTS_UNREADABLE,
|
|
7
|
+
type PaymentsClientOptions,
|
|
8
|
+
type PaymentsFailure,
|
|
9
|
+
restorePurchases,
|
|
10
|
+
submitPurchase,
|
|
11
|
+
} from "@pithy-sh/payments/src/client/api";
|
|
12
|
+
import type { PaymentsClientProduct } from "@pithy-sh/payments/src/client/projection";
|
|
13
|
+
import { type PaymentsPurchasePath, purchasePathFor } from "@pithy-sh/payments/src/client/railChoice";
|
|
14
|
+
import type { PriceVisitor } from "@pithy-sh/payments/src/pricing/location";
|
|
15
|
+
import { fetchPriceVisitor, type PriceVisitorOptions } from "@pithy-sh/payments/src/pricing/visitor";
|
|
16
|
+
import { useAction } from "@pithy-sh/ui-core/src/action";
|
|
17
|
+
import { useRead } from "@pithy-sh/ui-core/src/read";
|
|
18
|
+
import { randomUUID } from "expo-crypto";
|
|
19
|
+
import type { Purchase, RequestPurchaseProps } from "expo-iap";
|
|
20
|
+
import { useCallback, useRef } from "react";
|
|
21
|
+
import { loadStore, type StoreModule } from "./payments-store";
|
|
22
|
+
import { authConfig, paymentsConfig, platformRails } from "./pithy-config";
|
|
23
|
+
import { devicePlatform, onDevice, openExternal } from "./platform";
|
|
24
|
+
import { type PinnedCredential, secureStore, transport } from "./transport";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The bridge between the narrowed projection and `@pithy-sh/payments`' client. Everything the purchase flow does
|
|
28
|
+
* on the wire lives in that package and upgrades with it; this file binds it to this app.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Where the payments routes are and how to reach them, bound once. On a phone the calls carry the bearer
|
|
33
|
+
* credential to the Worker's origin, and a hosted page (a checkout, the billing portal) opens in the system
|
|
34
|
+
* browser; on the web they ride the page's cookie and the page itself leaves for the hosted page.
|
|
35
|
+
*/
|
|
36
|
+
export const paymentsClient: PaymentsClientOptions = {
|
|
37
|
+
basePath: paymentsConfig.basePath,
|
|
38
|
+
...transport.capability,
|
|
39
|
+
...(onDevice()
|
|
40
|
+
? {
|
|
41
|
+
navigate: (url: string) => {
|
|
42
|
+
void openExternal(url);
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
: {}),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** What Paddle.js starts with on the web, or null when this project has no Paddle rail. */
|
|
49
|
+
export const paddleSetup = paymentsConfig.paddle;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* How to sell `product` on this platform: in the app through the platform's store, on a hosted page, not at all
|
|
53
|
+
* (`none`), or `unavailable` here. From the rail `pithy-config.generated.ts` names for this platform and the rails
|
|
54
|
+
* the Worker has on.
|
|
55
|
+
*/
|
|
56
|
+
export function purchasePath(product: Pick<PaymentsClientProduct, "skus">): PaymentsPurchasePath {
|
|
57
|
+
return purchasePathFor({ platform: devicePlatform(), rails: platformRails, enabled: paymentsConfig.rails, product });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What a refusal reads as, in the reader's language: the catalog's sentence for the code when there is one, the
|
|
62
|
+
* Worker's own words otherwise.
|
|
63
|
+
*/
|
|
64
|
+
export function failureText(t: Translator, failure: PaymentsFailure): string {
|
|
65
|
+
return t.maybe(failure.code, failure.params) ?? failure.message;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether the visitor holds `key` right now. `true` when payments is not composed, so a guard cannot lock a screen
|
|
70
|
+
* shut; `false` when the read fails, because a guard fails shut. A courtesy, never the boundary.
|
|
71
|
+
*/
|
|
72
|
+
export async function holdsEntitlement(key: string): Promise<boolean> {
|
|
73
|
+
if (!paymentsConfig.enabled) return true;
|
|
74
|
+
const held = await getEntitlements(paymentsClient);
|
|
75
|
+
return held.ok && held.value.some((entitlement) => entitlement.key === key && entitlement.granted);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Who Paddle prices this visitor as, once there is a session to ask about. Null quotes from the IP estimate. */
|
|
79
|
+
export function usePriceVisitor(ask: boolean, options: PriceVisitorOptions = paymentsClient): PriceVisitor | null {
|
|
80
|
+
return (
|
|
81
|
+
useRead("price-visitor", () => fetchPriceVisitor(options), { skip: !ask || !paymentsConfig.enabled }).value ?? null
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The secure-store key one user's account token is kept under. Every character outside `[A-Za-z0-9.]` is escaped. */
|
|
86
|
+
function accountTokenKey(userId: string): string {
|
|
87
|
+
let escaped = "";
|
|
88
|
+
for (const char of userId) {
|
|
89
|
+
escaped += /[A-Za-z0-9.]/.test(char) ? char : `_${char.codePointAt(0)?.toString(16)}_`;
|
|
90
|
+
}
|
|
91
|
+
return `pithy.payments.account.${escaped}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
95
|
+
|
|
96
|
+
/** The secure-store key the account tokens minted on this device are listed under, whoever they belong to. */
|
|
97
|
+
const KNOWN_TOKENS_KEY = "pithy.payments.accounts";
|
|
98
|
+
|
|
99
|
+
/** The account tokens minted on this install, lowercased. Rejects when the store cannot answer or the list is unreadable. */
|
|
100
|
+
async function readKnownTokens(): Promise<Set<string>> {
|
|
101
|
+
const listed: unknown = JSON.parse((await secureStore.getItem(KNOWN_TOKENS_KEY)) ?? "[]");
|
|
102
|
+
if (!Array.isArray(listed)) throw new Error("The account token list is unreadable.");
|
|
103
|
+
return new Set(
|
|
104
|
+
listed.filter((token): token is string => typeof token === "string").map((token) => token.toLowerCase()),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The account tokens minted on this install, lowercased, or `null` when the store cannot say. */
|
|
109
|
+
async function knownAccountTokens(): Promise<ReadonlySet<string> | null> {
|
|
110
|
+
try {
|
|
111
|
+
return await readKnownTokens();
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** List `token` as one this install minted, unless it is already. A list that cannot be read is never rewritten. */
|
|
118
|
+
async function listKnownToken(token: string): Promise<void> {
|
|
119
|
+
const known = await readKnownTokens();
|
|
120
|
+
if (known.has(token.toLowerCase())) return;
|
|
121
|
+
known.add(token.toLowerCase());
|
|
122
|
+
await secureStore.setItem(KNOWN_TOKENS_KEY, JSON.stringify([...known]));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The token a store purchase is tagged with for this user on this device: Apple's `appAccountToken`, Google's
|
|
127
|
+
* `obfuscatedAccountId`. A random UUID, minted once per user and device and kept in the secure store, never derived
|
|
128
|
+
* from the user id: the Worker links a store account to whoever presents it first, so a guessable token would let
|
|
129
|
+
* somebody claim another's purchases.
|
|
130
|
+
*
|
|
131
|
+
* `null` on the web, which buys on a hosted page, and when the store cannot keep a new token: a purchase tagged
|
|
132
|
+
* with a token this device will not remember could not be restored to this account.
|
|
133
|
+
*/
|
|
134
|
+
export async function accountToken(userId: string): Promise<string | null> {
|
|
135
|
+
if (!onDevice()) return null;
|
|
136
|
+
const key = accountTokenKey(userId);
|
|
137
|
+
try {
|
|
138
|
+
const stored = await secureStore.getItem(key);
|
|
139
|
+
if (stored !== null && UUID_V4.test(stored)) {
|
|
140
|
+
// A token missing from the list, such as one whose listing a concurrent mint overwrote, is listed again.
|
|
141
|
+
await listKnownToken(stored).catch(() => undefined);
|
|
142
|
+
return stored;
|
|
143
|
+
}
|
|
144
|
+
const minted = randomUUID();
|
|
145
|
+
await listKnownToken(minted);
|
|
146
|
+
await secureStore.setItem(key, minted);
|
|
147
|
+
return minted;
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The same client, with a hosted page's return reported: on a phone `onReturn` runs once the system browser is
|
|
155
|
+
* closed, so a screen reads the subscription again the moment the reader is back. iOS shows the page over the app
|
|
156
|
+
* and the app never leaves the foreground, so this is the only signal there; on Android the browser is its own app
|
|
157
|
+
* and the return to the foreground signals it as well. On the web the page itself leaves, and nothing returns.
|
|
158
|
+
*/
|
|
159
|
+
export function returningClient(onReturn: () => void): PaymentsClientOptions {
|
|
160
|
+
if (!onDevice()) return paymentsClient;
|
|
161
|
+
return {
|
|
162
|
+
...paymentsClient,
|
|
163
|
+
navigate: (url: string) => {
|
|
164
|
+
void openExternal(url).then(onReturn);
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Whether `entitlements` grants every key `product` carries: the purchase a reader went to make has landed. */
|
|
170
|
+
export function grantsProduct(
|
|
171
|
+
product: Pick<PaymentsClientProduct, "entitlements">,
|
|
172
|
+
entitlements: readonly EntitlementView[],
|
|
173
|
+
): boolean {
|
|
174
|
+
return (
|
|
175
|
+
product.entitlements.length > 0 &&
|
|
176
|
+
product.entitlements.every((key) => entitlements.some((held) => held.key === key && held.granted))
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A Paddle checkout that opens over a page, which an app cannot draw: the Worker answered with a transaction for
|
|
182
|
+
* Paddle.js rather than a page to go to. Paddle's hosted mode answers with the page.
|
|
183
|
+
*/
|
|
184
|
+
export const PADDLE_IN_PAGE: PaymentsFailure = {
|
|
185
|
+
code: "payments/checkout.in_page",
|
|
186
|
+
message: "This checkout opens inside a web page, which the app can't show.",
|
|
187
|
+
action: 'Set `paddle.checkout: "hosted"` in pithy.config.ts, so Paddle answers with a checkout page.',
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** The store could not be reached, or could not answer: no connection to StoreKit or Play Billing. */
|
|
191
|
+
export const STORE_UNAVAILABLE: PaymentsFailure = {
|
|
192
|
+
code: "payments/store.unavailable",
|
|
193
|
+
message: "The store didn't answer. Try again in a moment.",
|
|
194
|
+
action: null,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The store had not answered a purchase by the deadline. It may still go through: one that does lands on the
|
|
199
|
+
* account without the reader doing anything again.
|
|
200
|
+
*/
|
|
201
|
+
export const STORE_NO_ANSWER: PaymentsFailure = {
|
|
202
|
+
code: "payments/store.no_answer",
|
|
203
|
+
message: "The store hasn't answered. If the purchase goes through, it lands on your account on its own.",
|
|
204
|
+
action: null,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** The store ended the purchase with an error other than the reader closing its sheet. */
|
|
208
|
+
export const STORE_REFUSED: PaymentsFailure = {
|
|
209
|
+
code: "payments/store.refused",
|
|
210
|
+
message: "The store didn't finish the purchase.",
|
|
211
|
+
action: null,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/** No account token could be kept on this device, so a purchase could not be tagged as this account's. */
|
|
215
|
+
export const STORE_NO_TOKEN: PaymentsFailure = {
|
|
216
|
+
code: "payments/store.no_token",
|
|
217
|
+
message: "This device couldn't keep what tags a purchase as yours, so nothing was bought.",
|
|
218
|
+
action: null,
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/** Nobody is signed in, and a purchase attaches to an account. */
|
|
222
|
+
export const STORE_SIGNED_OUT: PaymentsFailure = {
|
|
223
|
+
code: "payments/store.signed_out",
|
|
224
|
+
message: "Sign in before you buy.",
|
|
225
|
+
action: null,
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/** A product the store sells: its id there and the kind of thing it is. */
|
|
229
|
+
export interface StoreItem {
|
|
230
|
+
/** The App Store or Play product id. */
|
|
231
|
+
readonly sku: string;
|
|
232
|
+
/** The product's kind in the catalog. A subscription is asked for as one; everything else is a one-time item. */
|
|
233
|
+
readonly type: PaymentsClientProduct["type"];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** What the store says about one of its products. */
|
|
237
|
+
export interface StoreListing {
|
|
238
|
+
/** The price as the store renders it for this reader's storefront, currency and tax included. */
|
|
239
|
+
readonly displayPrice: string;
|
|
240
|
+
/** The Play offer a subscription is bought under, or null. */
|
|
241
|
+
readonly offerToken: string | null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The store's own name for a product's kind. */
|
|
245
|
+
function storeType(type: PaymentsClientProduct["type"]): "subs" | "in-app" {
|
|
246
|
+
return type === "subscription" ? "subs" : "in-app";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let connection: Promise<StoreModule | null> | null = null;
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The store, with its connection open: once per launch, and `null` where there is no store to reach (the web) or
|
|
253
|
+
* it refused the connection. A failed attempt is forgotten, so the next one tries again.
|
|
254
|
+
*/
|
|
255
|
+
function connectStore(): Promise<StoreModule | null> {
|
|
256
|
+
connection ??= (async () => {
|
|
257
|
+
const store = await loadStore();
|
|
258
|
+
if (store !== null) {
|
|
259
|
+
try {
|
|
260
|
+
if (await store.initConnection()) return store;
|
|
261
|
+
} catch {
|
|
262
|
+
// Refused below, like a connection the store declined.
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
connection = null;
|
|
266
|
+
return null;
|
|
267
|
+
})();
|
|
268
|
+
return connection;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The store's listing for each of `items`, by SKU. A product the store does not know is absent: that is a SKU
|
|
273
|
+
* missing from App Store Connect or the Play Console, not a price to guess. Rejects when the store cannot answer.
|
|
274
|
+
*/
|
|
275
|
+
export async function readStoreListings(items: readonly StoreItem[]): Promise<ReadonlyMap<string, StoreListing>> {
|
|
276
|
+
const store = await connectStore();
|
|
277
|
+
if (store === null) throw new Error(STORE_UNAVAILABLE.message);
|
|
278
|
+
const listings = new Map<string, StoreListing>();
|
|
279
|
+
for (const type of ["in-app", "subs"] as const) {
|
|
280
|
+
const skus = items.filter((item) => storeType(item.type) === type).map((item) => item.sku);
|
|
281
|
+
if (skus.length === 0) continue;
|
|
282
|
+
const answered = (await store.fetchProducts({ skus, type })) ?? [];
|
|
283
|
+
for (const product of answered) {
|
|
284
|
+
const offers = "subscriptionOffers" in product ? (product.subscriptionOffers ?? []) : [];
|
|
285
|
+
listings.set(product.id, {
|
|
286
|
+
displayPrice: product.displayPrice,
|
|
287
|
+
offerToken: offers.find((offer) => offer.offerTokenAndroid)?.offerTokenAndroid ?? null,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return listings;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The store's listings for `items`, read once per set of SKUs, and only on a phone: the web sells nothing through
|
|
296
|
+
* a store. `null` while reading and when the store could not answer, which a screen renders as no price at all.
|
|
297
|
+
*/
|
|
298
|
+
export function useStoreListings(items: readonly StoreItem[]): {
|
|
299
|
+
listings: ReadonlyMap<string, StoreListing> | null;
|
|
300
|
+
loading: boolean;
|
|
301
|
+
} {
|
|
302
|
+
const key = items.map((item) => `${item.type}:${item.sku}`).join(",");
|
|
303
|
+
const read = useRead(`store-listings:${key}`, () => readStoreListings(items), {
|
|
304
|
+
skip: items.length === 0 || !onDevice(),
|
|
305
|
+
});
|
|
306
|
+
return { listings: read.value ?? null, loading: read.loading };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* What a purchase is submitted to the Worker as: Apple's signed transaction, the JWS; Google's purchase as
|
|
311
|
+
* `getOriginalJson()` returns it, verbatim, because the Worker reads the token and the product from it. `null` when
|
|
312
|
+
* the store answered without one.
|
|
313
|
+
*/
|
|
314
|
+
export function storeReceipt(rail: "apple" | "google", purchase: Purchase): string | null {
|
|
315
|
+
const receipt = rail === "apple" ? purchase.purchaseToken : "dataAndroid" in purchase ? purchase.dataAndroid : null;
|
|
316
|
+
return typeof receipt === "string" && receipt !== "" ? receipt : null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Whether the store product `sku` is a consumable in this project's catalog, which the store finishes by consuming. */
|
|
320
|
+
function consumableSku(rail: "apple" | "google", sku: string): boolean {
|
|
321
|
+
return paymentsConfig.products.some((product) => product.skus[rail] === sku && product.type === "consumable");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Finish a transaction the Worker has accepted. The purchase is on the account either way: a store that fails to
|
|
326
|
+
* finish it hands it back on a later launch, and {@link watchStoreTransactions} submits it again, which the Worker
|
|
327
|
+
* answers with the purchase it already holds.
|
|
328
|
+
*/
|
|
329
|
+
async function finishAccepted(purchase: Purchase, isConsumable: boolean): Promise<void> {
|
|
330
|
+
const store = await connectStore();
|
|
331
|
+
try {
|
|
332
|
+
await store?.finishTransaction({ purchase, isConsumable });
|
|
333
|
+
} catch {
|
|
334
|
+
// Left with the store, which delivers it again.
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The SKUs a screen is waiting on the store's answer for, and the transactions a screen took as its answer. The
|
|
340
|
+
* store hands every purchase to every listener, so {@link watchStoreTransactions} leaves these to the screen.
|
|
341
|
+
*/
|
|
342
|
+
const awaitedSkus = new Map<string, number>();
|
|
343
|
+
const claimedTransactions = new Set<string>();
|
|
344
|
+
|
|
345
|
+
function awaitSku(sku: string): () => void {
|
|
346
|
+
awaitedSkus.set(sku, (awaitedSkus.get(sku) ?? 0) + 1);
|
|
347
|
+
return () => {
|
|
348
|
+
const left = (awaitedSkus.get(sku) ?? 1) - 1;
|
|
349
|
+
if (left === 0) awaitedSkus.delete(sku);
|
|
350
|
+
else awaitedSkus.set(sku, left);
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** How the store answered a purchase request. */
|
|
355
|
+
type StoreAnswer =
|
|
356
|
+
| { kind: "bought"; purchase: Purchase }
|
|
357
|
+
| { kind: "pending" }
|
|
358
|
+
| { kind: "cancelled" }
|
|
359
|
+
| { kind: "refused"; failure: PaymentsFailure };
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* What a store error means for the purchase: the reader closed the sheet, the purchase waits on someone else (Ask
|
|
363
|
+
* to Buy, a bank's check), which StoreKit reports as an error, or the store refused it.
|
|
364
|
+
*/
|
|
365
|
+
function storeErrorAnswer(store: StoreModule, error: unknown): StoreAnswer {
|
|
366
|
+
const code = typeof error === "object" && error !== null && "code" in error ? error.code : null;
|
|
367
|
+
if (code === store.ErrorCode.UserCancelled) return { kind: "cancelled" };
|
|
368
|
+
if (code === store.ErrorCode.DeferredPayment) return { kind: "pending" };
|
|
369
|
+
return { kind: "refused", failure: STORE_REFUSED };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The request StoreKit or Play Billing is asked to present, tagged with this account's token. */
|
|
373
|
+
function purchaseRequest(
|
|
374
|
+
rail: "apple" | "google",
|
|
375
|
+
item: StoreItem,
|
|
376
|
+
token: string,
|
|
377
|
+
offerToken: string | null,
|
|
378
|
+
): RequestPurchaseProps {
|
|
379
|
+
const { sku } = item;
|
|
380
|
+
if (storeType(item.type) === "subs") {
|
|
381
|
+
return {
|
|
382
|
+
type: "subs",
|
|
383
|
+
request:
|
|
384
|
+
rail === "apple"
|
|
385
|
+
? { apple: { sku, appAccountToken: token } }
|
|
386
|
+
: {
|
|
387
|
+
google: {
|
|
388
|
+
skus: [sku],
|
|
389
|
+
obfuscatedAccountId: token,
|
|
390
|
+
...(offerToken === null ? {} : { subscriptionOffers: [{ sku, offerToken }] }),
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
type: "in-app",
|
|
397
|
+
request:
|
|
398
|
+
rail === "apple"
|
|
399
|
+
? { apple: { sku, appAccountToken: token } }
|
|
400
|
+
: { google: { skus: [sku], obfuscatedAccountId: token } },
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* How long a purchase waits for the store's answer before the screen stops waiting: long enough for a reader to
|
|
406
|
+
* get through the store's sheet, Face ID and a password included. A purchase that completes after it is still
|
|
407
|
+
* submitted, by {@link watchStoreTransactions}.
|
|
408
|
+
*/
|
|
409
|
+
export const STORE_ANSWER_WITHIN = 180_000;
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Ask the store to sell `item`, and wait for its answer, for at most `answerWithin` ms. The answer arrives on the
|
|
413
|
+
* store's listeners rather than as the request's result, so they are attached first and removed once this
|
|
414
|
+
* product's answer is in, or the deadline passes.
|
|
415
|
+
*/
|
|
416
|
+
function askStore(
|
|
417
|
+
rail: "apple" | "google",
|
|
418
|
+
item: StoreItem,
|
|
419
|
+
token: string,
|
|
420
|
+
answerWithin: number,
|
|
421
|
+
): Promise<StoreAnswer> {
|
|
422
|
+
const buyer = token.toLowerCase();
|
|
423
|
+
return new Promise<StoreAnswer>((resolve) => {
|
|
424
|
+
let settled = false;
|
|
425
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
426
|
+
const listeners: { remove: () => void }[] = [];
|
|
427
|
+
const release = awaitSku(item.sku);
|
|
428
|
+
const settle = (answer: StoreAnswer): void => {
|
|
429
|
+
if (settled) return;
|
|
430
|
+
settled = true;
|
|
431
|
+
clearTimeout(timer);
|
|
432
|
+
for (const listener of listeners) listener.remove();
|
|
433
|
+
if (answer.kind === "bought") claimedTransactions.add(answer.purchase.id);
|
|
434
|
+
release();
|
|
435
|
+
resolve(answer);
|
|
436
|
+
};
|
|
437
|
+
timer = setTimeout(() => settle({ kind: "refused", failure: STORE_NO_ANSWER }), answerWithin);
|
|
438
|
+
|
|
439
|
+
void (async () => {
|
|
440
|
+
const store = await connectStore();
|
|
441
|
+
if (settled) return;
|
|
442
|
+
if (store === null) return settle({ kind: "refused", failure: STORE_UNAVAILABLE });
|
|
443
|
+
let offerToken: string | null = null;
|
|
444
|
+
if (rail === "google" && storeType(item.type) === "subs") {
|
|
445
|
+
try {
|
|
446
|
+
offerToken = (await readStoreListings([item])).get(item.sku)?.offerToken ?? null;
|
|
447
|
+
} catch {
|
|
448
|
+
return settle({ kind: "refused", failure: STORE_UNAVAILABLE });
|
|
449
|
+
}
|
|
450
|
+
if (settled) return;
|
|
451
|
+
}
|
|
452
|
+
listeners.push(
|
|
453
|
+
store.purchaseUpdatedListener((purchase: Purchase) => {
|
|
454
|
+
if (purchase.productId !== item.sku) return;
|
|
455
|
+
// A transaction the store replays, or holds for another account, is never this buyer's answer: not a
|
|
456
|
+
// purchase, and not a pending one either.
|
|
457
|
+
if (purchaseAccountToken(rail, purchase) !== buyer) return;
|
|
458
|
+
settle(purchase.purchaseState === "pending" ? { kind: "pending" } : { kind: "bought", purchase });
|
|
459
|
+
}),
|
|
460
|
+
store.purchaseErrorListener((error) => {
|
|
461
|
+
if (error.productId && error.productId !== item.sku) return;
|
|
462
|
+
settle(storeErrorAnswer(store, error));
|
|
463
|
+
}),
|
|
464
|
+
);
|
|
465
|
+
store
|
|
466
|
+
.requestPurchase(purchaseRequest(rail, item, token, offerToken))
|
|
467
|
+
.catch((error: unknown) => settle(storeErrorAnswer(store, error)));
|
|
468
|
+
})();
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The store rail this app sells through on this device, or `null`: the web, a phone set to a web rail or `none`,
|
|
474
|
+
* a store rail the Worker does not have on, and payments not composed all have no store to listen to.
|
|
475
|
+
*/
|
|
476
|
+
export function storeRailHere(): "apple" | "google" | null {
|
|
477
|
+
if (!paymentsConfig.enabled) return null;
|
|
478
|
+
const platform = devicePlatform();
|
|
479
|
+
const own = platform === "ios" ? "apple" : platform === "android" ? "google" : null;
|
|
480
|
+
if (own === null || platformRails[platform] !== own) return null;
|
|
481
|
+
return paymentsConfig.rails[own] ? own : null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The account token `purchase` was tagged with when it was bought, lowercased, or `null` when it carries none.
|
|
486
|
+
* StoreKit renders a UUID in capitals; the token this app mints is lowercase.
|
|
487
|
+
*/
|
|
488
|
+
function purchaseAccountToken(rail: "apple" | "google", purchase: Purchase): string | null {
|
|
489
|
+
const token =
|
|
490
|
+
rail === "apple"
|
|
491
|
+
? "appAccountToken" in purchase
|
|
492
|
+
? purchase.appAccountToken
|
|
493
|
+
: null
|
|
494
|
+
: "obfuscatedAccountIdAndroid" in purchase
|
|
495
|
+
? purchase.obfuscatedAccountIdAndroid
|
|
496
|
+
: null;
|
|
497
|
+
return typeof token === "string" && token !== "" ? token.toLowerCase() : null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Whether a purchase tagged `tagged` may go to the Worker for the user whose token here is `own`: their own token,
|
|
502
|
+
* or a token this install never minted, which is theirs from an earlier install or another device. A token minted
|
|
503
|
+
* here for another user is that user's, and stays with the store for them. When the list of tokens minted here
|
|
504
|
+
* cannot be read (`known` is `null`), only their own token goes. The Worker's owner checks decide the rest.
|
|
505
|
+
*/
|
|
506
|
+
function taggedForOwner(tagged: string, own: string, known: ReadonlySet<string> | null): boolean {
|
|
507
|
+
return tagged === own || (known !== null && !known.has(tagged));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** The id of whoever `credential` belongs to, or `null` when nobody. */
|
|
511
|
+
async function credentialUserId(credential: PinnedCredential): Promise<string | null> {
|
|
512
|
+
try {
|
|
513
|
+
const session = await getSession(credential.auth);
|
|
514
|
+
return session.ok ? (session.value?.user.id ?? null) : null;
|
|
515
|
+
} catch {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** The account token of whoever `credential` belongs to on this device, lowercased, or `null` when nobody. */
|
|
521
|
+
async function credentialAccountToken(credential: PinnedCredential): Promise<string | null> {
|
|
522
|
+
const userId = await credentialUserId(credential);
|
|
523
|
+
if (userId === null) return null;
|
|
524
|
+
return (await accountToken(userId))?.toLowerCase() ?? null;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* The purchases the store still holds unfinished: StoreKit's pending transactions, Play's unacknowledged
|
|
529
|
+
* purchases. Empty when the store cannot answer.
|
|
530
|
+
*/
|
|
531
|
+
async function unfinishedPurchases(rail: "apple" | "google", store: StoreModule): Promise<Purchase[]> {
|
|
532
|
+
try {
|
|
533
|
+
if (rail === "apple") return await store.getPendingTransactionsIOS();
|
|
534
|
+
const held = await store.getAvailablePurchases();
|
|
535
|
+
return held.filter((purchase) => "isAcknowledgedAndroid" in purchase && purchase.isAcknowledgedAndroid === false);
|
|
536
|
+
} catch {
|
|
537
|
+
return [];
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Submit the purchases the store delivers while no screen is waiting on them, and finish each once the Worker has
|
|
543
|
+
* accepted it: a purchase interrupted before it was finished, which the store hands back at launch; an Ask to Buy
|
|
544
|
+
* request once it is approved; a subscription's renewal, which keeps the token it was first bought with. A purchase
|
|
545
|
+
* the Worker refuses, or that could not reach it, stays with the store, which delivers it again.
|
|
546
|
+
*
|
|
547
|
+
* Only a purchase tagged for the signed-in user is submitted: with their account token on this device, or with a
|
|
548
|
+
* token this install never minted, which is theirs from an earlier install or another device. One tagged for another
|
|
549
|
+
* user of this device, or with no token, is left unfinished and untouched, so the store keeps it for the user it
|
|
550
|
+
* belongs to; with nobody signed in, nothing is submitted. Each sign-in and sign-out reads the store's unfinished
|
|
551
|
+
* purchases again, so one left for a user goes through once that user signs in on this device.
|
|
552
|
+
*
|
|
553
|
+
* The store connection opens at launch, which is what starts StoreKit's and Play's deliveries, and the purchases
|
|
554
|
+
* they still hold unfinished are read then too. Answers a function that stops listening. The root layout runs this
|
|
555
|
+
* through `useStoreTransactions`.
|
|
556
|
+
*/
|
|
557
|
+
export function watchStoreTransactions(rail: "apple" | "google"): () => void {
|
|
558
|
+
let stopped = false;
|
|
559
|
+
let listener: { remove: () => void } | null = null;
|
|
560
|
+
const inFlight = new Set<string>();
|
|
561
|
+
|
|
562
|
+
const settle = async (purchase: Purchase): Promise<void> => {
|
|
563
|
+
if (purchase.purchaseState === "pending") return;
|
|
564
|
+
if (awaitedSkus.has(purchase.productId) || claimedTransactions.has(purchase.id)) return;
|
|
565
|
+
const tagged = purchaseAccountToken(rail, purchase);
|
|
566
|
+
if (tagged === null) return;
|
|
567
|
+
const receipt = storeReceipt(rail, purchase);
|
|
568
|
+
if (receipt === null || !authConfig.enabled || inFlight.has(purchase.id)) return;
|
|
569
|
+
inFlight.add(purchase.id);
|
|
570
|
+
try {
|
|
571
|
+
// One credential answers whose purchase this is and carries it, so a sign-in landing in between can never
|
|
572
|
+
// send one user's purchase under the next user's credential.
|
|
573
|
+
const credential = await transport.pinned();
|
|
574
|
+
if (credential === null || stopped) return;
|
|
575
|
+
const owner = await credentialAccountToken(credential);
|
|
576
|
+
if (stopped || owner === null || !taggedForOwner(tagged, owner, await knownAccountTokens())) return;
|
|
577
|
+
const written = await submitPurchase({ rail, receipt }, { ...paymentsClient, ...credential.capability });
|
|
578
|
+
if (written.ok) await finishAccepted(purchase, consumableSku(rail, purchase.productId));
|
|
579
|
+
} finally {
|
|
580
|
+
inFlight.delete(purchase.id);
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
const scan = async (): Promise<void> => {
|
|
585
|
+
const store = await connectStore();
|
|
586
|
+
if (store === null || stopped) return;
|
|
587
|
+
for (const purchase of await unfinishedPurchases(rail, store)) {
|
|
588
|
+
if (stopped) return;
|
|
589
|
+
await settle(purchase);
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
const unwatch = transport.onChange(() => {
|
|
594
|
+
void scan();
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
void (async () => {
|
|
598
|
+
const store = await loadStore();
|
|
599
|
+
if (store === null || stopped) return;
|
|
600
|
+
listener = store.purchaseUpdatedListener((purchase: Purchase) => {
|
|
601
|
+
void settle(purchase);
|
|
602
|
+
});
|
|
603
|
+
// Opening the connection is what starts StoreKit's and Play's deliveries; the scan reads what is already held.
|
|
604
|
+
await scan();
|
|
605
|
+
})();
|
|
606
|
+
|
|
607
|
+
return () => {
|
|
608
|
+
stopped = true;
|
|
609
|
+
unwatch();
|
|
610
|
+
listener?.remove();
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** A refusal carried through `useAction` as a thrown error, so the screen reads it back as a failure. */
|
|
615
|
+
class StoreRefusal extends Error {
|
|
616
|
+
constructor(readonly failure: PaymentsFailure) {
|
|
617
|
+
super(failure.message);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Who a store sale is made for: one credential, and the account token of the user it belongs to here. */
|
|
622
|
+
interface Buyer {
|
|
623
|
+
readonly credential: PinnedCredential;
|
|
624
|
+
readonly token: string;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* The signed-in user, pinned: the same credential answers who they are and carries everything the sale sends, so a
|
|
629
|
+
* sign-in landing while the store's sheet is open can never send one user's purchase under the next user's.
|
|
630
|
+
* Refuses with nobody signed in, and when this device cannot keep a token to tag the purchase with.
|
|
631
|
+
*/
|
|
632
|
+
async function pinBuyer(): Promise<Buyer> {
|
|
633
|
+
if (!authConfig.enabled) throw new StoreRefusal(STORE_SIGNED_OUT);
|
|
634
|
+
const credential = await transport.pinned();
|
|
635
|
+
if (credential === null) throw new StoreRefusal(STORE_SIGNED_OUT);
|
|
636
|
+
const userId = await credentialUserId(credential);
|
|
637
|
+
if (userId === null) throw new StoreRefusal(STORE_SIGNED_OUT);
|
|
638
|
+
const token = await accountToken(userId);
|
|
639
|
+
if (token === null) throw new StoreRefusal(STORE_NO_TOKEN);
|
|
640
|
+
return { credential, token: token.toLowerCase() };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** What the last store action came to. */
|
|
644
|
+
export type StoreSaleOutcome = "bought" | "pending" | "cancelled" | "refused" | "restored" | "nothing_to_restore";
|
|
645
|
+
|
|
646
|
+
/** What {@link useStoreSale} gives a screen that sells through the platform's store. */
|
|
647
|
+
export interface StoreSale {
|
|
648
|
+
/** Buy `item` through `rail`: the store's sheet, then the Worker, then the store's transaction is finished. */
|
|
649
|
+
buy: (rail: "apple" | "google", item: StoreItem) => Promise<void>;
|
|
650
|
+
/**
|
|
651
|
+
* Restore Purchases: the store's history on `rail`, submitted to the Worker for the signed-in user. A purchase
|
|
652
|
+
* tagged for another user on this device is left for that user.
|
|
653
|
+
*/
|
|
654
|
+
restore: (rail: "apple" | "google") => Promise<void>;
|
|
655
|
+
/** Whether a purchase or a restore is in flight. */
|
|
656
|
+
busy: boolean;
|
|
657
|
+
/** What the last one came to, or null before any. */
|
|
658
|
+
outcome: StoreSaleOutcome | null;
|
|
659
|
+
/** Why the last one failed, the store's refusal or the Worker's, or null. */
|
|
660
|
+
failure: PaymentsFailure | null;
|
|
661
|
+
/** The caller's entitlements as the Worker answered the last purchase or restore, empty before one. */
|
|
662
|
+
entitlements: readonly EntitlementView[];
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** Restore takes at most this many receipts per request. */
|
|
666
|
+
const RESTORE_BATCH = 50;
|
|
667
|
+
|
|
668
|
+
/** What one store action came to, and the entitlements the Worker answered with when it wrote one. */
|
|
669
|
+
interface StoreSaleAnswer {
|
|
670
|
+
readonly outcome: StoreSaleOutcome;
|
|
671
|
+
readonly entitlements: readonly EntitlementView[] | null;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const NO_ENTITLEMENTS: readonly EntitlementView[] = [];
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Buying in the app, through StoreKit or Play Billing. The store's sheet takes the payment and answers with a
|
|
678
|
+
* signed purchase; it goes to the Worker's `/purchases` route, and the store's transaction is finished only once
|
|
679
|
+
* the Worker has accepted it. A refused or unsent purchase stays with the store, which hands it back on the next
|
|
680
|
+
* launch or a restore, so a buyer is never charged for something their account does not hold.
|
|
681
|
+
*
|
|
682
|
+
* Each purchase is tagged with this user's account token on this device, which is how the Worker links the store
|
|
683
|
+
* account to the buyer, and is sent under the credential the buyer was read from. A store that has not answered
|
|
684
|
+
* within `answerWithin` ms ({@link STORE_ANSWER_WITHIN} by default) ends the wait with {@link STORE_NO_ANSWER}.
|
|
685
|
+
*/
|
|
686
|
+
export function useStoreSale(options: { answerWithin?: number } = {}): StoreSale {
|
|
687
|
+
const answerWithin = options.answerWithin ?? STORE_ANSWER_WITHIN;
|
|
688
|
+
const sale = useAction(
|
|
689
|
+
async (
|
|
690
|
+
_signal,
|
|
691
|
+
call: { kind: "buy"; rail: "apple" | "google"; item: StoreItem } | { kind: "restore"; rail: "apple" | "google" },
|
|
692
|
+
): Promise<StoreSaleAnswer> => {
|
|
693
|
+
if (!onDevice()) throw new StoreRefusal(call.kind === "buy" ? STORE_NO_TOKEN : STORE_UNAVAILABLE);
|
|
694
|
+
const { credential, token } = await pinBuyer();
|
|
695
|
+
const client = { ...paymentsClient, ...credential.capability };
|
|
696
|
+
|
|
697
|
+
if (call.kind === "buy") {
|
|
698
|
+
const answer = await askStore(call.rail, call.item, token, answerWithin);
|
|
699
|
+
if (answer.kind === "refused") throw new StoreRefusal(answer.failure);
|
|
700
|
+
if (answer.kind !== "bought") return { outcome: answer.kind, entitlements: null };
|
|
701
|
+
const receipt = storeReceipt(call.rail, answer.purchase);
|
|
702
|
+
if (receipt === null) throw new StoreRefusal(PAYMENTS_UNREADABLE);
|
|
703
|
+
const written = await submitPurchase({ rail: call.rail, receipt }, client);
|
|
704
|
+
if (!written.ok) throw new StoreRefusal(written.failure);
|
|
705
|
+
await finishAccepted(answer.purchase, call.item.type === "consumable");
|
|
706
|
+
return { outcome: "bought", entitlements: written.value.entitlements };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const store = await connectStore();
|
|
710
|
+
if (store === null) throw new StoreRefusal(STORE_UNAVAILABLE);
|
|
711
|
+
let held: Purchase[];
|
|
712
|
+
try {
|
|
713
|
+
await store.restorePurchases();
|
|
714
|
+
held = await store.getAvailablePurchases();
|
|
715
|
+
} catch {
|
|
716
|
+
throw new StoreRefusal(STORE_UNAVAILABLE);
|
|
717
|
+
}
|
|
718
|
+
const known = await knownAccountTokens();
|
|
719
|
+
// A purchase tagged for another user of this device stays with the store for that user.
|
|
720
|
+
const receipted = held.flatMap((purchase) => {
|
|
721
|
+
const tagged = purchaseAccountToken(call.rail, purchase);
|
|
722
|
+
if (tagged !== null && !taggedForOwner(tagged, token, known)) return [];
|
|
723
|
+
const receipt = storeReceipt(call.rail, purchase);
|
|
724
|
+
return receipt === null ? [] : [{ purchase, receipt }];
|
|
725
|
+
});
|
|
726
|
+
if (receipted.length === 0) return { outcome: "nothing_to_restore", entitlements: null };
|
|
727
|
+
let entitlements: readonly EntitlementView[] | null = null;
|
|
728
|
+
for (let start = 0; start < receipted.length; start += RESTORE_BATCH) {
|
|
729
|
+
const batch = receipted.slice(start, start + RESTORE_BATCH);
|
|
730
|
+
const written = await restorePurchases(
|
|
731
|
+
{ rail: call.rail, receipts: batch.map((entry) => entry.receipt) },
|
|
732
|
+
client,
|
|
733
|
+
);
|
|
734
|
+
if (!written.ok) throw new StoreRefusal(written.failure);
|
|
735
|
+
entitlements = written.value.entitlements;
|
|
736
|
+
for (const { purchase } of batch) {
|
|
737
|
+
await finishAccepted(purchase, consumableSku(call.rail, purchase.productId));
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return { outcome: "restored", entitlements };
|
|
741
|
+
},
|
|
742
|
+
);
|
|
743
|
+
const run = sale.run;
|
|
744
|
+
const buy = useCallback(
|
|
745
|
+
async (rail: "apple" | "google", item: StoreItem) => {
|
|
746
|
+
await run({ kind: "buy", rail, item });
|
|
747
|
+
},
|
|
748
|
+
[run],
|
|
749
|
+
);
|
|
750
|
+
const restore = useCallback(
|
|
751
|
+
async (rail: "apple" | "google") => {
|
|
752
|
+
await run({ kind: "restore", rail });
|
|
753
|
+
},
|
|
754
|
+
[run],
|
|
755
|
+
);
|
|
756
|
+
// The entitlements the Worker last answered with, kept through an outcome that wrote nothing.
|
|
757
|
+
const entitlements = useRef(NO_ENTITLEMENTS);
|
|
758
|
+
if (sale.value?.entitlements) entitlements.current = sale.value.entitlements;
|
|
759
|
+
const refused = sale.failure instanceof StoreRefusal ? sale.failure.failure : sale.failure && STORE_UNAVAILABLE;
|
|
760
|
+
return {
|
|
761
|
+
buy,
|
|
762
|
+
restore,
|
|
763
|
+
busy: sale.pending,
|
|
764
|
+
// `useAction` keeps the last success through a later failure; the outcome is the last call's.
|
|
765
|
+
outcome: sale.pending ? null : refused ? "refused" : (sale.value?.outcome ?? null),
|
|
766
|
+
failure: sale.pending ? null : refused,
|
|
767
|
+
entitlements: entitlements.current,
|
|
768
|
+
};
|
|
769
|
+
}
|