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