cranpose_services/purchases.rs
1//! In-app purchases: products, prices and owned entitlements.
2//!
3//! The shape is the one every mobile store agrees on — ask for a set of
4//! product ids, get back localized prices, start a purchase, and be told what
5//! the account owns — with the store-specific parts (StoreKit, Play Billing)
6//! living in platform backends installed via [`set_platform_purchases`].
7//!
8//! **The default backend reports [`StorePhase::Unavailable`] and owns
9//! nothing.** It deliberately does *not* grant entitlements: a desktop build
10//! with no store must not silently unlock paid features because a backend
11//! failed to register. An app that ships free on storeless platforms decides
12//! that itself, e.g.
13//!
14//! ```no_run
15//! # use cranpose_services::purchases::store_state;
16//! let state = store_state();
17//! // No store that will sell here — this app is free in that case.
18//! let unlocked = state.phase.cannot_sell() || state.owns("com.example.pro");
19//! ```
20//!
21//! The two phases that cannot sell say different things to a *user*:
22//! [`StorePhase::Unavailable`] is "not reached, try again", and
23//! [`StorePhase::Blocked`] is "this store will not sell to you here". Offering
24//! a retry for the second one only fails the same way again.
25//!
26//! # Reading state
27//!
28//! [`store_state`] is a cheap snapshot, safe to call every frame: backends
29//! keep the state and hand out a clone. State changes arrive asynchronously
30//! (the store answers over the network, another device restores a purchase,
31//! a parent approves an Ask-to-Buy request), so read it from the frame loop
32//! rather than expecting a reply to [`purchase`].
33//!
34//! [`take_event`] drains one-shot events — the things a snapshot cannot
35//! express, like "the user cancelled" — for showing a message once.
36
37#[cfg(not(target_arch = "wasm32"))]
38use std::sync::Mutex;
39use std::{
40 collections::{BTreeMap, BTreeSet},
41 sync::{
42 Arc, OnceLock,
43 atomic::{AtomicU64, Ordering},
44 },
45};
46
47use crate::registry::{RecoveryGate, ServiceRegistry};
48
49/// A product as the store describes it, in the user's locale and currency.
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct Product {
52 /// Store product identifier, as configured in App Store Connect or the
53 /// Play Console.
54 pub id: String,
55 /// Price formatted by the store for the user's storefront — "$34.99",
56 /// "34,99 €", "¥5,000". **Always display this string**; never format a
57 /// price yourself, and never hard-code one. Stores localize currency,
58 /// separators and placement, and they apply regional price tiers.
59 pub display_price: String,
60 /// Display name configured in the store.
61 pub title: String,
62 /// Description configured in the store.
63 pub description: String,
64}
65
66/// How far along the store connection is.
67#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
68pub enum StorePhase {
69 /// No store on this platform, or no backend installed, or one that has
70 /// not reached the store yet. Nothing is owned and nothing can be bought
71 /// *right now* — a backend that is retrying reports this, so an app may
72 /// reasonably say "not reached" and offer to try again.
73 #[default]
74 Unavailable,
75 /// The store answered, and it will not sell to this app here: in-app
76 /// billing turned off on the device, an account that cannot pay, a
77 /// country the app is not distributed in.
78 ///
79 /// The difference from [`Unavailable`](Self::Unavailable) is whether
80 /// waiting helps. It does not here — no backend retries a store that has
81 /// said no — so an app should stop offering the purchase and say why,
82 /// rather than inviting a retry that can only fail the same way.
83 /// Already-known ownership remains authoritative even though the store
84 /// cannot be queried for new purchases.
85 Blocked,
86 /// A backend is installed and still talking to the store. Prices are not
87 /// known yet; owned entitlements may not be known yet either.
88 Connecting,
89 /// Product and entitlement information has been received at least once.
90 Ready,
91}
92
93impl StorePhase {
94 /// Whether nothing can be bought in this phase.
95 ///
96 /// Saves every paywall from spelling out the same two-variant match, and
97 /// keeps an app that only cares "can I sell?" from having to be updated
98 /// when a phase is added.
99 pub fn cannot_sell(self) -> bool {
100 matches!(self, Self::Unavailable | Self::Blocked)
101 }
102
103 /// Whether the store might still answer differently later.
104 ///
105 /// True while a backend is connecting or has yet to reach the store,
106 /// false once the store has said no or has already answered.
107 pub fn may_yet_change(self) -> bool {
108 matches!(self, Self::Unavailable | Self::Connecting)
109 }
110}
111
112/// Snapshot of everything known about the store right now.
113#[derive(Clone, Debug, Default, PartialEq, Eq)]
114pub struct StoreState {
115 /// How far along the connection is.
116 pub phase: StorePhase,
117 /// Products the backend was configured with and the store answered for.
118 /// A configured product missing here is one the store does not know —
119 /// usually a typo in the id, or a product not yet approved.
120 pub products: Vec<Product>,
121 /// Product ids the account currently owns. For non-consumables and
122 /// subscriptions this is the entitlement; consumables never appear.
123 pub owned: BTreeSet<String>,
124 /// The store's identifier for the purchase that granted each owned
125 /// product — Play's order id, StoreKit's transaction id.
126 ///
127 /// Separate from [`owned`](Self::owned) rather than replacing it, because
128 /// a backend can know that a product is owned without knowing what paid
129 /// for it: Play's `queryPurchases` omits the order id for a test purchase,
130 /// and a restore on a reinstalled app can report ownership before the
131 /// receipt is back. Ownership is the entitlement; this is only the paper
132 /// trail. Never gate access on it.
133 ///
134 /// An app that keeps a local record of the purchase wants it: with only
135 /// the product id there is nothing to quote to the store, or to the user,
136 /// if the entitlement is ever in dispute.
137 pub orders: BTreeMap<String, String>,
138 /// Last error reported by the store, for diagnostics. A store being
139 /// briefly unreachable is normal and not worth showing to the user.
140 pub error: Option<String>,
141 /// True while a purchase or restore the user asked for is still running,
142 /// so the UI can disable the buy button and show a spinner.
143 pub busy: bool,
144}
145
146impl StoreState {
147 /// Whether `product_id` is currently owned.
148 pub fn owns(&self, product_id: &str) -> bool {
149 self.owned.contains(product_id)
150 }
151
152 /// The store's identifier for the purchase that granted `product_id`, if
153 /// the backend reported one. See [`orders`](Self::orders): absent is
154 /// normal and does not mean unowned.
155 pub fn order_id(&self, product_id: &str) -> Option<&str> {
156 self.orders.get(product_id).map(String::as_str)
157 }
158
159 /// The product with `product_id`, if the store answered for it.
160 pub fn product(&self, product_id: &str) -> Option<&Product> {
161 self.products.iter().find(|p| p.id == product_id)
162 }
163
164 /// The localized price of `product_id`, if known.
165 pub fn display_price(&self, product_id: &str) -> Option<&str> {
166 self.product(product_id).map(|p| p.display_price.as_str())
167 }
168}
169
170/// A one-shot thing that happened, which a snapshot cannot express.
171#[derive(Clone, Debug, PartialEq, Eq)]
172pub enum PurchaseEvent {
173 /// The purchase completed and the entitlement is in [`StoreState::owned`].
174 Purchased(String),
175 /// The user dismissed the payment sheet. Not an error; say nothing.
176 Cancelled,
177 /// The purchase needs someone else to finish it — Ask to Buy, or a
178 /// bank-side confirmation. It may complete minutes or days later, so tell
179 /// the user it is pending rather than that it failed.
180 Pending,
181 /// The purchase failed. The string is for the user.
182 Failed(String),
183 /// A restore finished. `restored` is how many entitlements it found —
184 /// zero means "nothing to restore on this account", which is worth
185 /// saying, because the user asked.
186 Restored {
187 /// Number of owned entitlements the restore turned up.
188 restored: usize,
189 },
190}
191
192/// A store backend.
193///
194/// Implementations are installed with [`set_platform_purchases`] and must be
195/// non-blocking: every method returns immediately and reports back by
196/// updating the snapshot returned from [`Purchases::state`].
197pub trait Purchases: Send + Sync {
198 /// Declare the product ids this app sells and start talking to the store.
199 /// Called again on relaunch; backends should treat it as idempotent.
200 fn configure(&self, product_ids: &[&str]);
201
202 /// The current snapshot. Called every frame — keep it cheap.
203 fn state(&self) -> StoreState;
204
205 /// Begin a purchase. Presents the store's own payment sheet.
206 fn purchase(&self, product_id: &str);
207
208 /// Re-query what the account owns. Stores restore silently at launch, so
209 /// this is for the explicit "Restore purchases" button that Apple
210 /// requires a paid app to provide.
211 fn restore(&self);
212
213 /// Take the next pending one-shot event, if any.
214 fn take_event(&self) -> Option<PurchaseEvent>;
215
216 fn is_connected(&self) -> bool;
217
218 fn reconnect(&self);
219}
220
221/// Shared handle to the active [`Purchases`] backend.
222pub type PurchasesRef = Arc<dyn Purchases>;
223
224struct NoPurchases;
225
226impl Purchases for NoPurchases {
227 fn configure(&self, _product_ids: &[&str]) {}
228
229 fn state(&self) -> StoreState {
230 StoreState::default()
231 }
232
233 fn purchase(&self, _product_id: &str) {}
234
235 fn restore(&self) {}
236
237 fn take_event(&self) -> Option<PurchaseEvent> {
238 None
239 }
240
241 fn is_connected(&self) -> bool {
242 false
243 }
244
245 fn reconnect(&self) {}
246}
247
248static PLATFORM_PURCHASES: ServiceRegistry<dyn Purchases> = ServiceRegistry::new();
249static NO_PURCHASES: OnceLock<PurchasesRef> = OnceLock::new();
250static DEFAULT_PURCHASES: OnceLock<PurchasesRef> = OnceLock::new();
251static PURCHASE_RECOVERY: RecoveryGate = RecoveryGate::new();
252
253struct PlatformPurchases;
254
255fn registered_purchases() -> PurchasesRef {
256 PLATFORM_PURCHASES
257 .get_or_warn("purchases")
258 .unwrap_or_else(|| NO_PURCHASES.get_or_init(|| Arc::new(NoPurchases)).clone())
259}
260
261fn active_purchases() -> PurchasesRef {
262 let purchases = registered_purchases();
263 if purchases.is_connected() {
264 PURCHASE_RECOVERY.succeeded();
265 } else if PURCHASE_RECOVERY.try_start() {
266 purchases.reconnect();
267 }
268 purchases
269}
270
271impl Purchases for PlatformPurchases {
272 fn configure(&self, product_ids: &[&str]) {
273 active_purchases().configure(product_ids);
274 }
275
276 fn state(&self) -> StoreState {
277 active_purchases().state()
278 }
279
280 fn purchase(&self, product_id: &str) {
281 active_purchases().purchase(product_id);
282 }
283
284 fn restore(&self) {
285 active_purchases().restore();
286 }
287
288 fn take_event(&self) -> Option<PurchaseEvent> {
289 active_purchases().take_event()
290 }
291
292 fn is_connected(&self) -> bool {
293 registered_purchases().is_connected()
294 }
295
296 fn reconnect(&self) {
297 registered_purchases().reconnect();
298 }
299}
300
301#[cfg(not(target_arch = "wasm32"))]
302type StoreListener = Arc<dyn Fn() + Send + Sync>;
303#[cfg(target_arch = "wasm32")]
304type StoreListener = std::rc::Rc<dyn Fn()>;
305
306#[cfg(not(target_arch = "wasm32"))]
307fn store_listeners() -> &'static Mutex<Vec<(u64, StoreListener)>> {
308 static LISTENERS: OnceLock<Mutex<Vec<(u64, StoreListener)>>> = OnceLock::new();
309 LISTENERS.get_or_init(|| Mutex::new(Vec::new()))
310}
311
312#[cfg(target_arch = "wasm32")]
313thread_local! {
314 static STORE_LISTENERS: std::cell::RefCell<Vec<(u64, StoreListener)>> = const { std::cell::RefCell::new(Vec::new()) };
315}
316
317static NEXT_STORE_LISTENER_ID: AtomicU64 = AtomicU64::new(1);
318
319/// A store observer installed by [`observe_store_news`].
320pub struct StoreObserver {
321 id: u64,
322}
323
324impl Drop for StoreObserver {
325 fn drop(&mut self) {
326 #[cfg(not(target_arch = "wasm32"))]
327 if let Ok(mut listeners) = store_listeners().lock() {
328 listeners.retain(|(id, _)| *id != self.id);
329 }
330 #[cfg(target_arch = "wasm32")]
331 STORE_LISTENERS.with(|listeners| listeners.borrow_mut().retain(|(id, _)| *id != self.id));
332 }
333}
334
335/// Registers a callback run whenever the store has news, so an app can be told
336/// rather than having to ask.
337///
338/// [`take_event`] and [`store_state`] are polling APIs, which assume the app is
339/// already running a frame loop to poll from. An app that has gone idle has no
340/// such loop, so a purchase that finishes while nothing moves on screen sits in
341/// the queue until something unrelated wakes the app. The listener closes that
342/// gap: it is the nudge, the queue is still the source of truth.
343///
344/// Native callbacks can arrive from a platform thread and therefore must be
345/// `Send + Sync`. Browser callbacks remain on their browser thread.
346#[cfg(not(target_arch = "wasm32"))]
347pub fn observe_store_news(listener: impl Fn() + Send + Sync + 'static) -> StoreObserver {
348 let id = NEXT_STORE_LISTENER_ID.fetch_add(1, Ordering::Relaxed);
349 if let Ok(mut listeners) = store_listeners().lock() {
350 listeners.push((id, Arc::new(listener)));
351 }
352 StoreObserver { id }
353}
354
355/// Registers a browser-thread callback run whenever the store has news.
356#[cfg(target_arch = "wasm32")]
357pub fn observe_store_news(listener: impl Fn() + 'static) -> StoreObserver {
358 let id = NEXT_STORE_LISTENER_ID.fetch_add(1, Ordering::Relaxed);
359 STORE_LISTENERS.with(|listeners| {
360 listeners
361 .borrow_mut()
362 .push((id, std::rc::Rc::new(listener)));
363 });
364 StoreObserver { id }
365}
366
367/// Tells the app that the store has news. Called by a purchase backend.
368pub fn note_store_news() {
369 #[cfg(not(target_arch = "wasm32"))]
370 let listeners = store_listeners()
371 .lock()
372 .map(|listeners| {
373 listeners
374 .iter()
375 .map(|(_, listener)| Arc::clone(listener))
376 .collect::<Vec<_>>()
377 })
378 .unwrap_or_default();
379 #[cfg(target_arch = "wasm32")]
380 let listeners = STORE_LISTENERS.with(|listeners| {
381 listeners
382 .borrow()
383 .iter()
384 .map(|(_, listener)| std::rc::Rc::clone(listener))
385 .collect::<Vec<_>>()
386 });
387 for listener in listeners {
388 listener();
389 }
390}
391
392pub fn set_platform_purchases(purchases: PurchasesRef) {
393 PLATFORM_PURCHASES.set(purchases);
394 PURCHASE_RECOVERY.succeeded();
395}
396
397/// Removes any registered purchase backend (tests and teardown).
398pub fn clear_platform_purchases() {
399 PLATFORM_PURCHASES.clear();
400}
401
402/// The active backend: the platform one if installed, else the no-store
403/// backend.
404pub fn purchases() -> PurchasesRef {
405 DEFAULT_PURCHASES
406 .get_or_init(|| Arc::new(PlatformPurchases))
407 .clone()
408}
409
410/// Whether a real store backend is installed on this platform.
411pub fn store_available() -> bool {
412 PLATFORM_PURCHASES.get().is_some()
413}
414
415/// Convenience: declare the products this app sells and connect to the store.
416pub fn configure(product_ids: &[&str]) {
417 purchases().configure(product_ids);
418}
419
420/// Convenience: the current store snapshot.
421pub fn store_state() -> StoreState {
422 purchases().state()
423}
424
425/// Convenience: begin a purchase.
426pub fn purchase(product_id: &str) {
427 purchases().purchase(product_id);
428}
429
430/// Convenience: re-query owned entitlements.
431pub fn restore() {
432 purchases().restore();
433}
434
435/// Convenience: take the next one-shot purchase event.
436pub fn take_event() -> Option<PurchaseEvent> {
437 purchases().take_event()
438}
439
440/// The store's current state, observed for as long as this call stays in the
441/// composition.
442///
443/// The composition recomposes when the backend publishes news; nothing polls
444/// and no frame loop is required for a purchase that completes while the screen
445/// is idle.
446#[expect(non_snake_case)]
447#[track_caller]
448pub fn rememberStoreState() -> cranpose_core::State<StoreState> {
449 let updates = cranpose_core::rememberEventStream((), |sender| {
450 observe_store_news(move || sender.send(store_state()))
451 });
452 cranpose_core::collectAsState(updates, (), store_state())
453}
454
455/// One-shot purchase outcomes as a composition-scoped stream.
456///
457/// Each event is delivered exactly once. Collect it with
458/// [`cranpose_core::CollectEvents`].
459#[expect(non_snake_case)]
460#[track_caller]
461pub fn rememberPurchaseEvents() -> cranpose_core::EventStream<PurchaseEvent> {
462 cranpose_core::rememberEventStream((), |sender| {
463 observe_store_news(move || {
464 while let Some(event) = take_event() {
465 sender.send(event);
466 }
467 })
468 })
469}
470
471#[cfg(test)]
472#[path = "tests/purchases_tests.rs"]
473mod tests;