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, StorePhase};
16//! let unlocked = match store_state().phase {
17//!     // No store on this platform — this app is free there.
18//!     StorePhase::Unavailable => true,
19//!     _ => store_state().owns("com.example.pro"),
20//! };
21//! ```
22//!
23//! # Reading state
24//!
25//! [`store_state`] is a cheap snapshot, safe to call every frame: backends
26//! keep the state and hand out a clone. State changes arrive asynchronously
27//! (the store answers over the network, another device restores a purchase,
28//! a parent approves an Ask-to-Buy request), so read it from the frame loop
29//! rather than expecting a reply to [`purchase`].
30//!
31//! [`take_event`] drains one-shot events — the things a snapshot cannot
32//! express, like "the user cancelled" — for showing a message once.
33
34use std::cell::RefCell;
35use std::collections::BTreeSet;
36use std::rc::Rc;
37
38/// A product as the store describes it, in the user's locale and currency.
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct Product {
41    /// Store product identifier, as configured in App Store Connect or the
42    /// Play Console.
43    pub id: String,
44    /// Price formatted by the store for the user's storefront — "$34.99",
45    /// "34,99 €", "¥5,000". **Always display this string**; never format a
46    /// price yourself, and never hard-code one. Stores localize currency,
47    /// separators and placement, and they apply regional price tiers.
48    pub display_price: String,
49    /// Display name configured in the store.
50    pub title: String,
51    /// Description configured in the store.
52    pub description: String,
53}
54
55/// How far along the store connection is.
56#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
57pub enum StorePhase {
58    /// No store on this platform, or no backend installed. Nothing is owned
59    /// and nothing can be bought.
60    #[default]
61    Unavailable,
62    /// A backend is installed and still talking to the store. Prices are not
63    /// known yet; owned entitlements may not be known yet either.
64    Connecting,
65    /// Product and entitlement information has been received at least once.
66    Ready,
67}
68
69/// Snapshot of everything known about the store right now.
70#[derive(Clone, Debug, Default, PartialEq, Eq)]
71pub struct StoreState {
72    /// How far along the connection is.
73    pub phase: StorePhase,
74    /// Products the backend was configured with and the store answered for.
75    /// A configured product missing here is one the store does not know —
76    /// usually a typo in the id, or a product not yet approved.
77    pub products: Vec<Product>,
78    /// Product ids the account currently owns. For non-consumables and
79    /// subscriptions this is the entitlement; consumables never appear.
80    pub owned: BTreeSet<String>,
81    /// Last error reported by the store, for diagnostics. A store being
82    /// briefly unreachable is normal and not worth showing to the user.
83    pub error: Option<String>,
84    /// True while a purchase or restore the user asked for is still running,
85    /// so the UI can disable the buy button and show a spinner.
86    pub busy: bool,
87}
88
89impl StoreState {
90    /// Whether `product_id` is currently owned.
91    pub fn owns(&self, product_id: &str) -> bool {
92        self.owned.contains(product_id)
93    }
94
95    /// The product with `product_id`, if the store answered for it.
96    pub fn product(&self, product_id: &str) -> Option<&Product> {
97        self.products.iter().find(|p| p.id == product_id)
98    }
99
100    /// The localized price of `product_id`, if known.
101    pub fn display_price(&self, product_id: &str) -> Option<&str> {
102        self.product(product_id).map(|p| p.display_price.as_str())
103    }
104}
105
106/// A one-shot thing that happened, which a snapshot cannot express.
107#[derive(Clone, Debug, PartialEq, Eq)]
108pub enum PurchaseEvent {
109    /// The purchase completed and the entitlement is in [`StoreState::owned`].
110    Purchased(String),
111    /// The user dismissed the payment sheet. Not an error; say nothing.
112    Cancelled,
113    /// The purchase needs someone else to finish it — Ask to Buy, or a
114    /// bank-side confirmation. It may complete minutes or days later, so tell
115    /// the user it is pending rather than that it failed.
116    Pending,
117    /// The purchase failed. The string is for the user.
118    Failed(String),
119    /// A restore finished. `restored` is how many entitlements it found —
120    /// zero means "nothing to restore on this account", which is worth
121    /// saying, because the user asked.
122    Restored {
123        /// Number of owned entitlements the restore turned up.
124        restored: usize,
125    },
126}
127
128/// A store backend.
129///
130/// Implementations are installed with [`set_platform_purchases`] and must be
131/// non-blocking: every method returns immediately and reports back by
132/// updating the snapshot returned from [`Purchases::state`].
133pub trait Purchases {
134    /// Declare the product ids this app sells and start talking to the store.
135    /// Called again on relaunch; backends should treat it as idempotent.
136    fn configure(&self, product_ids: &[&str]);
137
138    /// The current snapshot. Called every frame — keep it cheap.
139    fn state(&self) -> StoreState;
140
141    /// Begin a purchase. Presents the store's own payment sheet.
142    fn purchase(&self, product_id: &str);
143
144    /// Re-query what the account owns. Stores restore silently at launch, so
145    /// this is for the explicit "Restore purchases" button that Apple
146    /// requires a paid app to provide.
147    fn restore(&self);
148
149    /// Take the next pending one-shot event, if any.
150    fn take_event(&self) -> Option<PurchaseEvent>;
151}
152
153/// Shared handle to the active [`Purchases`] backend.
154pub type PurchasesRef = Rc<dyn Purchases>;
155
156/// The no-store backend: nothing is for sale and nothing is owned.
157struct NoPurchases;
158
159impl Purchases for NoPurchases {
160    fn configure(&self, _product_ids: &[&str]) {}
161
162    fn state(&self) -> StoreState {
163        StoreState::default()
164    }
165
166    fn purchase(&self, _product_id: &str) {}
167
168    fn restore(&self) {}
169
170    fn take_event(&self) -> Option<PurchaseEvent> {
171        None
172    }
173}
174
175thread_local! {
176    static PLATFORM_PURCHASES: RefCell<Option<PurchasesRef>> = const { RefCell::new(None) };
177    /// The no-store backend, created once per thread. [`purchases`] is on the
178    /// frame path — an app polls the snapshot every frame — so the fallback
179    /// must be a reference-count bump, not a fresh allocation each call.
180    static NO_PURCHASES: PurchasesRef = Rc::new(NoPurchases);
181}
182
183/// Installs a platform purchase backend, replacing any previous one.
184static STORE_LISTENER: std::sync::OnceLock<Box<dyn Fn() + Send + Sync>> =
185    std::sync::OnceLock::new();
186
187/// Registers a callback run whenever the store has news, so an app can be told
188/// rather than having to ask.
189///
190/// [`take_event`] and [`store_state`] are polling APIs, which assume the app is
191/// already running a frame loop to poll from. An app that has gone idle has no
192/// such loop, so a purchase that finishes while nothing moves on screen sits in
193/// the queue until something unrelated wakes the app. The listener closes that
194/// gap: it is the nudge, the queue is still the source of truth.
195///
196/// Called from whatever thread the platform reports on, so the callback must be
197/// `Send + Sync` and should do as little as possible.
198pub fn set_store_listener(listener: impl Fn() + Send + Sync + 'static) {
199    let _ = STORE_LISTENER.set(Box::new(listener));
200}
201
202/// Tells the app that the store has news. Called by a purchase backend.
203pub fn note_store_news() {
204    if let Some(listener) = STORE_LISTENER.get() {
205        listener();
206    }
207}
208
209pub fn set_platform_purchases(purchases: PurchasesRef) {
210    PLATFORM_PURCHASES.with(|cell| *cell.borrow_mut() = Some(purchases));
211}
212
213/// Removes any registered purchase backend (tests and teardown).
214pub fn clear_platform_purchases() {
215    PLATFORM_PURCHASES.with(|cell| *cell.borrow_mut() = None);
216}
217
218/// The active backend: the platform one if installed, else the no-store
219/// backend.
220pub fn purchases() -> PurchasesRef {
221    PLATFORM_PURCHASES
222        .with(|cell| cell.borrow().clone())
223        .unwrap_or_else(|| NO_PURCHASES.with(Rc::clone))
224}
225
226/// Whether a real store backend is installed on this platform.
227pub fn store_available() -> bool {
228    PLATFORM_PURCHASES.with(|cell| cell.borrow().is_some())
229}
230
231/// Convenience: declare the products this app sells and connect to the store.
232pub fn configure(product_ids: &[&str]) {
233    purchases().configure(product_ids);
234}
235
236/// Convenience: the current store snapshot.
237pub fn store_state() -> StoreState {
238    purchases().state()
239}
240
241/// Convenience: begin a purchase.
242pub fn purchase(product_id: &str) {
243    purchases().purchase(product_id);
244}
245
246/// Convenience: re-query owned entitlements.
247pub fn restore() {
248    purchases().restore();
249}
250
251/// Convenience: take the next one-shot purchase event.
252pub fn take_event() -> Option<PurchaseEvent> {
253    purchases().take_event()
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn default_backend_sells_nothing_and_owns_nothing() {
262        clear_platform_purchases();
263        let state = store_state();
264        assert_eq!(state.phase, StorePhase::Unavailable);
265        assert!(state.owned.is_empty());
266        assert!(!state.owns("com.example.pro"));
267        assert!(!store_available());
268        // Calling through with no backend must not panic.
269        configure(&["com.example.pro"]);
270        purchase("com.example.pro");
271        restore();
272        assert_eq!(take_event(), None);
273    }
274
275    #[test]
276    fn installed_backend_answers_prices_and_ownership() {
277        struct Fake;
278        impl Purchases for Fake {
279            fn configure(&self, _product_ids: &[&str]) {}
280            fn state(&self) -> StoreState {
281                StoreState {
282                    phase: StorePhase::Ready,
283                    products: vec![Product {
284                        id: "com.example.pro".into(),
285                        display_price: "34,99 €".into(),
286                        title: "Pro".into(),
287                        description: "Everything unlocked".into(),
288                    }],
289                    owned: BTreeSet::from(["com.example.pro".to_string()]),
290                    error: None,
291                    busy: false,
292                }
293            }
294            fn purchase(&self, _product_id: &str) {}
295            fn restore(&self) {}
296            fn take_event(&self) -> Option<PurchaseEvent> {
297                Some(PurchaseEvent::Purchased("com.example.pro".into()))
298            }
299        }
300        set_platform_purchases(Rc::new(Fake));
301        let state = store_state();
302        assert_eq!(state.phase, StorePhase::Ready);
303        assert!(state.owns("com.example.pro"));
304        assert_eq!(state.display_price("com.example.pro"), Some("34,99 €"));
305        assert_eq!(state.display_price("com.example.nope"), None);
306        assert!(store_available());
307        assert_eq!(
308            take_event(),
309            Some(PurchaseEvent::Purchased("com.example.pro".into()))
310        );
311        clear_platform_purchases();
312    }
313}