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