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::{
42        atomic::{AtomicU64, Ordering},
43        Arc, OnceLock,
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
224/// The no-store backend: nothing is for sale and nothing is owned.
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/// Installs a platform purchase backend, replacing any previous one.
303#[cfg(not(target_arch = "wasm32"))]
304type StoreListener = Arc<dyn Fn() + Send + Sync>;
305#[cfg(target_arch = "wasm32")]
306type StoreListener = std::rc::Rc<dyn Fn()>;
307
308#[cfg(not(target_arch = "wasm32"))]
309fn store_listeners() -> &'static Mutex<Vec<(u64, StoreListener)>> {
310    static LISTENERS: OnceLock<Mutex<Vec<(u64, StoreListener)>>> = OnceLock::new();
311    LISTENERS.get_or_init(|| Mutex::new(Vec::new()))
312}
313
314#[cfg(target_arch = "wasm32")]
315thread_local! {
316    static STORE_LISTENERS: std::cell::RefCell<Vec<(u64, StoreListener)>> = const { std::cell::RefCell::new(Vec::new()) };
317}
318
319static NEXT_STORE_LISTENER_ID: AtomicU64 = AtomicU64::new(1);
320
321/// A store observer installed by [`observe_store_news`].
322pub struct StoreObserver {
323    id: u64,
324}
325
326impl Drop for StoreObserver {
327    fn drop(&mut self) {
328        #[cfg(not(target_arch = "wasm32"))]
329        if let Ok(mut listeners) = store_listeners().lock() {
330            listeners.retain(|(id, _)| *id != self.id);
331        }
332        #[cfg(target_arch = "wasm32")]
333        STORE_LISTENERS.with(|listeners| listeners.borrow_mut().retain(|(id, _)| *id != self.id));
334    }
335}
336
337/// Registers a callback run whenever the store has news, so an app can be told
338/// rather than having to ask.
339///
340/// [`take_event`] and [`store_state`] are polling APIs, which assume the app is
341/// already running a frame loop to poll from. An app that has gone idle has no
342/// such loop, so a purchase that finishes while nothing moves on screen sits in
343/// the queue until something unrelated wakes the app. The listener closes that
344/// gap: it is the nudge, the queue is still the source of truth.
345///
346/// Native callbacks can arrive from a platform thread and therefore must be
347/// `Send + Sync`. Browser callbacks remain on their browser thread.
348#[cfg(not(target_arch = "wasm32"))]
349pub fn observe_store_news(listener: impl Fn() + Send + Sync + 'static) -> StoreObserver {
350    let id = NEXT_STORE_LISTENER_ID.fetch_add(1, Ordering::Relaxed);
351    if let Ok(mut listeners) = store_listeners().lock() {
352        listeners.push((id, Arc::new(listener)));
353    }
354    StoreObserver { id }
355}
356
357/// Registers a browser-thread callback run whenever the store has news.
358#[cfg(target_arch = "wasm32")]
359pub fn observe_store_news(listener: impl Fn() + 'static) -> StoreObserver {
360    let id = NEXT_STORE_LISTENER_ID.fetch_add(1, Ordering::Relaxed);
361    STORE_LISTENERS.with(|listeners| {
362        listeners
363            .borrow_mut()
364            .push((id, std::rc::Rc::new(listener)))
365    });
366    StoreObserver { id }
367}
368
369/// Tells the app that the store has news. Called by a purchase backend.
370pub fn note_store_news() {
371    #[cfg(not(target_arch = "wasm32"))]
372    let listeners = store_listeners()
373        .lock()
374        .map(|listeners| {
375            listeners
376                .iter()
377                .map(|(_, listener)| Arc::clone(listener))
378                .collect::<Vec<_>>()
379        })
380        .unwrap_or_default();
381    #[cfg(target_arch = "wasm32")]
382    let listeners = STORE_LISTENERS.with(|listeners| {
383        listeners
384            .borrow()
385            .iter()
386            .map(|(_, listener)| std::rc::Rc::clone(listener))
387            .collect::<Vec<_>>()
388    });
389    for listener in listeners {
390        listener();
391    }
392}
393
394pub fn set_platform_purchases(purchases: PurchasesRef) {
395    PLATFORM_PURCHASES.set(purchases);
396    PURCHASE_RECOVERY.succeeded();
397}
398
399/// Removes any registered purchase backend (tests and teardown).
400pub fn clear_platform_purchases() {
401    PLATFORM_PURCHASES.clear();
402}
403
404/// The active backend: the platform one if installed, else the no-store
405/// backend.
406pub fn purchases() -> PurchasesRef {
407    DEFAULT_PURCHASES
408        .get_or_init(|| Arc::new(PlatformPurchases))
409        .clone()
410}
411
412/// Whether a real store backend is installed on this platform.
413pub fn store_available() -> bool {
414    PLATFORM_PURCHASES.get().is_some()
415}
416
417/// Convenience: declare the products this app sells and connect to the store.
418pub fn configure(product_ids: &[&str]) {
419    purchases().configure(product_ids);
420}
421
422/// Convenience: the current store snapshot.
423pub fn store_state() -> StoreState {
424    purchases().state()
425}
426
427/// Convenience: begin a purchase.
428pub fn purchase(product_id: &str) {
429    purchases().purchase(product_id);
430}
431
432/// Convenience: re-query owned entitlements.
433pub fn restore() {
434    purchases().restore();
435}
436
437/// Convenience: take the next one-shot purchase event.
438pub fn take_event() -> Option<PurchaseEvent> {
439    purchases().take_event()
440}
441
442/// The store's current state, observed for as long as this call stays in the
443/// composition.
444///
445/// The composition recomposes when the backend publishes news; nothing polls
446/// and no frame loop is required for a purchase that completes while the screen
447/// is idle.
448#[allow(non_snake_case)]
449pub fn rememberStoreState() -> cranpose_core::State<StoreState> {
450    let updates = cranpose_core::rememberEventStream((), |sender| {
451        observe_store_news(move || sender.send(store_state()))
452    });
453    cranpose_core::collectAsState(updates, (), store_state())
454}
455
456/// One-shot purchase outcomes as a composition-scoped stream.
457///
458/// Each event is delivered exactly once. Collect it with
459/// [`cranpose_core::CollectEvents`].
460#[allow(non_snake_case)]
461pub fn rememberPurchaseEvents() -> cranpose_core::EventStream<PurchaseEvent> {
462    cranpose_core::rememberEventStream((), |sender| {
463        observe_store_news(move || {
464            // The backend queues events and nudges; draining here is the
465            // framework's job, and the application only ever sees the stream.
466            while let Some(event) = take_event() {
467                sender.send(event);
468            }
469        })
470    })
471}
472
473#[cfg(test)]
474mod tests {
475    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
476
477    use super::*;
478
479    #[test]
480    fn default_backend_sells_nothing_and_owns_nothing() {
481        let _guard = crate::registry::test_service_guard();
482        clear_platform_purchases();
483        let state = store_state();
484        assert_eq!(state.phase, StorePhase::Unavailable);
485        assert!(state.owned.is_empty());
486        assert!(!state.owns("com.example.pro"));
487        assert!(!store_available());
488        // Calling through with no backend must not panic.
489        configure(&["com.example.pro"]);
490        purchase("com.example.pro");
491        restore();
492        assert_eq!(take_event(), None);
493    }
494
495    #[test]
496    fn the_two_phases_that_cannot_sell_differ_on_whether_waiting_helps() {
497        assert!(StorePhase::Unavailable.cannot_sell());
498        assert!(StorePhase::Blocked.cannot_sell());
499        assert!(!StorePhase::Connecting.cannot_sell());
500        assert!(!StorePhase::Ready.cannot_sell());
501
502        assert!(StorePhase::Unavailable.may_yet_change());
503        assert!(StorePhase::Connecting.may_yet_change());
504        assert!(
505            !StorePhase::Blocked.may_yet_change(),
506            "a store that has said no is what the phase exists to say"
507        );
508        assert!(!StorePhase::Ready.may_yet_change());
509    }
510
511    #[test]
512    fn nothing_is_owned_by_default_and_blocked_is_not_the_default() {
513        // The default has to stay the phase that invites a retry: a backend
514        // that has not answered yet must not read as one that refused.
515        assert_eq!(StorePhase::default(), StorePhase::Unavailable);
516    }
517
518    #[test]
519    fn installed_backend_answers_prices_and_ownership() {
520        let _guard = crate::registry::test_service_guard();
521        struct Fake;
522        impl Purchases for Fake {
523            fn configure(&self, _product_ids: &[&str]) {}
524            fn state(&self) -> StoreState {
525                StoreState {
526                    phase: StorePhase::Ready,
527                    products: vec![Product {
528                        id: "com.example.pro".into(),
529                        display_price: "34,99 €".into(),
530                        title: "Pro".into(),
531                        description: "Everything unlocked".into(),
532                    }],
533                    owned: BTreeSet::from(["com.example.pro".to_string()]),
534                    orders: BTreeMap::from([(
535                        "com.example.pro".to_string(),
536                        "GPA.1234-5678".to_string(),
537                    )]),
538                    error: None,
539                    busy: false,
540                }
541            }
542            fn purchase(&self, _product_id: &str) {}
543            fn restore(&self) {}
544            fn take_event(&self) -> Option<PurchaseEvent> {
545                Some(PurchaseEvent::Purchased("com.example.pro".into()))
546            }
547            fn is_connected(&self) -> bool {
548                true
549            }
550            fn reconnect(&self) {}
551        }
552        set_platform_purchases(Arc::new(Fake));
553        let state = store_state();
554        assert_eq!(state.phase, StorePhase::Ready);
555        assert!(state.owns("com.example.pro"));
556        assert_eq!(state.order_id("com.example.pro"), Some("GPA.1234-5678"));
557        // Absent is normal: a backend can know a product is owned without
558        // knowing what paid for it, so this must never read as "not owned".
559        assert_eq!(state.order_id("com.example.free"), None);
560        assert_eq!(state.display_price("com.example.pro"), Some("34,99 €"));
561        assert_eq!(state.display_price("com.example.nope"), None);
562        assert!(store_available());
563        assert_eq!(
564            take_event(),
565            Some(PurchaseEvent::Purchased("com.example.pro".into()))
566        );
567        clear_platform_purchases();
568    }
569
570    #[test]
571    fn dead_store_reconnects_before_frame_state_is_read() {
572        let _guard = crate::registry::test_service_guard();
573        struct Reconnecting {
574            alive: AtomicBool,
575            reconnects: AtomicUsize,
576        }
577        impl Purchases for Reconnecting {
578            fn configure(&self, _product_ids: &[&str]) {}
579            fn state(&self) -> StoreState {
580                StoreState {
581                    phase: if self.alive.load(Ordering::Acquire) {
582                        StorePhase::Ready
583                    } else {
584                        StorePhase::Unavailable
585                    },
586                    ..StoreState::default()
587                }
588            }
589            fn purchase(&self, _product_id: &str) {}
590            fn restore(&self) {}
591            fn take_event(&self) -> Option<PurchaseEvent> {
592                None
593            }
594            fn is_connected(&self) -> bool {
595                self.alive.load(Ordering::Acquire)
596            }
597            fn reconnect(&self) {
598                self.reconnects.fetch_add(1, Ordering::AcqRel);
599                self.alive.store(true, Ordering::Release);
600            }
601        }
602        clear_platform_purchases();
603        let purchases = Arc::new(Reconnecting {
604            alive: AtomicBool::new(false),
605            reconnects: AtomicUsize::new(0),
606        });
607        set_platform_purchases(purchases.clone());
608        assert_eq!(store_state().phase, StorePhase::Ready);
609        assert_eq!(purchases.reconnects.load(Ordering::Acquire), 1);
610        clear_platform_purchases();
611    }
612
613    #[test]
614    fn store_observers_receive_news_until_dropped() {
615        let calls = Arc::new(AtomicUsize::new(0));
616        let seen = Arc::clone(&calls);
617        let observer = observe_store_news(move || {
618            seen.fetch_add(1, Ordering::Relaxed);
619        });
620        note_store_news();
621        assert_eq!(calls.load(Ordering::Relaxed), 1);
622        drop(observer);
623        note_store_news();
624        assert_eq!(calls.load(Ordering::Relaxed), 1);
625    }
626}