Skip to main content

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