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 std::cell::RefCell;
38use std::collections::{BTreeMap, BTreeSet};
39use std::rc::Rc;
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 {
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
209/// Shared handle to the active [`Purchases`] backend.
210pub type PurchasesRef = Rc<dyn Purchases>;
211
212/// The no-store backend: nothing is for sale and nothing is owned.
213struct NoPurchases;
214
215impl Purchases for NoPurchases {
216 fn configure(&self, _product_ids: &[&str]) {}
217
218 fn state(&self) -> StoreState {
219 StoreState::default()
220 }
221
222 fn purchase(&self, _product_id: &str) {}
223
224 fn restore(&self) {}
225
226 fn take_event(&self) -> Option<PurchaseEvent> {
227 None
228 }
229}
230
231thread_local! {
232 static PLATFORM_PURCHASES: RefCell<Option<PurchasesRef>> = const { RefCell::new(None) };
233 /// The no-store backend, created once per thread. [`purchases`] is on the
234 /// frame path — an app polls the snapshot every frame — so the fallback
235 /// must be a reference-count bump, not a fresh allocation each call.
236 static NO_PURCHASES: PurchasesRef = Rc::new(NoPurchases);
237}
238
239/// Installs a platform purchase backend, replacing any previous one.
240static STORE_LISTENER: std::sync::OnceLock<Box<dyn Fn() + Send + Sync>> =
241 std::sync::OnceLock::new();
242
243/// Registers a callback run whenever the store has news, so an app can be told
244/// rather than having to ask.
245///
246/// [`take_event`] and [`store_state`] are polling APIs, which assume the app is
247/// already running a frame loop to poll from. An app that has gone idle has no
248/// such loop, so a purchase that finishes while nothing moves on screen sits in
249/// the queue until something unrelated wakes the app. The listener closes that
250/// gap: it is the nudge, the queue is still the source of truth.
251///
252/// Called from whatever thread the platform reports on, so the callback must be
253/// `Send + Sync` and should do as little as possible.
254pub fn set_store_listener(listener: impl Fn() + Send + Sync + 'static) {
255 let _ = STORE_LISTENER.set(Box::new(listener));
256}
257
258/// Tells the app that the store has news. Called by a purchase backend.
259pub fn note_store_news() {
260 if let Some(listener) = STORE_LISTENER.get() {
261 listener();
262 }
263}
264
265pub fn set_platform_purchases(purchases: PurchasesRef) {
266 PLATFORM_PURCHASES.with(|cell| *cell.borrow_mut() = Some(purchases));
267}
268
269/// Removes any registered purchase backend (tests and teardown).
270pub fn clear_platform_purchases() {
271 PLATFORM_PURCHASES.with(|cell| *cell.borrow_mut() = None);
272}
273
274/// The active backend: the platform one if installed, else the no-store
275/// backend.
276pub fn purchases() -> PurchasesRef {
277 PLATFORM_PURCHASES
278 .with(|cell| cell.borrow().clone())
279 .unwrap_or_else(|| NO_PURCHASES.with(Rc::clone))
280}
281
282/// Whether a real store backend is installed on this platform.
283pub fn store_available() -> bool {
284 PLATFORM_PURCHASES.with(|cell| cell.borrow().is_some())
285}
286
287/// Convenience: declare the products this app sells and connect to the store.
288pub fn configure(product_ids: &[&str]) {
289 purchases().configure(product_ids);
290}
291
292/// Convenience: the current store snapshot.
293pub fn store_state() -> StoreState {
294 purchases().state()
295}
296
297/// Convenience: begin a purchase.
298pub fn purchase(product_id: &str) {
299 purchases().purchase(product_id);
300}
301
302/// Convenience: re-query owned entitlements.
303pub fn restore() {
304 purchases().restore();
305}
306
307/// Convenience: take the next one-shot purchase event.
308pub fn take_event() -> Option<PurchaseEvent> {
309 purchases().take_event()
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 #[test]
317 fn default_backend_sells_nothing_and_owns_nothing() {
318 clear_platform_purchases();
319 let state = store_state();
320 assert_eq!(state.phase, StorePhase::Unavailable);
321 assert!(state.owned.is_empty());
322 assert!(!state.owns("com.example.pro"));
323 assert!(!store_available());
324 // Calling through with no backend must not panic.
325 configure(&["com.example.pro"]);
326 purchase("com.example.pro");
327 restore();
328 assert_eq!(take_event(), None);
329 }
330
331 #[test]
332 fn the_two_phases_that_cannot_sell_differ_on_whether_waiting_helps() {
333 assert!(StorePhase::Unavailable.cannot_sell());
334 assert!(StorePhase::Blocked.cannot_sell());
335 assert!(!StorePhase::Connecting.cannot_sell());
336 assert!(!StorePhase::Ready.cannot_sell());
337
338 assert!(StorePhase::Unavailable.may_yet_change());
339 assert!(StorePhase::Connecting.may_yet_change());
340 assert!(
341 !StorePhase::Blocked.may_yet_change(),
342 "a store that has said no is what the phase exists to say"
343 );
344 assert!(!StorePhase::Ready.may_yet_change());
345 }
346
347 #[test]
348 fn nothing_is_owned_by_default_and_blocked_is_not_the_default() {
349 // The default has to stay the phase that invites a retry: a backend
350 // that has not answered yet must not read as one that refused.
351 assert_eq!(StorePhase::default(), StorePhase::Unavailable);
352 }
353
354 #[test]
355 fn installed_backend_answers_prices_and_ownership() {
356 struct Fake;
357 impl Purchases for Fake {
358 fn configure(&self, _product_ids: &[&str]) {}
359 fn state(&self) -> StoreState {
360 StoreState {
361 phase: StorePhase::Ready,
362 products: vec![Product {
363 id: "com.example.pro".into(),
364 display_price: "34,99 €".into(),
365 title: "Pro".into(),
366 description: "Everything unlocked".into(),
367 }],
368 owned: BTreeSet::from(["com.example.pro".to_string()]),
369 orders: BTreeMap::from([(
370 "com.example.pro".to_string(),
371 "GPA.1234-5678".to_string(),
372 )]),
373 error: None,
374 busy: false,
375 }
376 }
377 fn purchase(&self, _product_id: &str) {}
378 fn restore(&self) {}
379 fn take_event(&self) -> Option<PurchaseEvent> {
380 Some(PurchaseEvent::Purchased("com.example.pro".into()))
381 }
382 }
383 set_platform_purchases(Rc::new(Fake));
384 let state = store_state();
385 assert_eq!(state.phase, StorePhase::Ready);
386 assert!(state.owns("com.example.pro"));
387 assert_eq!(state.order_id("com.example.pro"), Some("GPA.1234-5678"));
388 // Absent is normal: a backend can know a product is owned without
389 // knowing what paid for it, so this must never read as "not owned".
390 assert_eq!(state.order_id("com.example.free"), None);
391 assert_eq!(state.display_price("com.example.pro"), Some("34,99 €"));
392 assert_eq!(state.display_price("com.example.nope"), None);
393 assert!(store_available());
394 assert_eq!(
395 take_event(),
396 Some(PurchaseEvent::Purchased("com.example.pro".into()))
397 );
398 clear_platform_purchases();
399 }
400}