rovenue/offerings/
client.rs

1use std::sync::Arc;
2
3use crate::cache::offerings::OfferingsCacheRepo;
4use crate::cache::CacheStore;
5use crate::error::{ErrorKind, RovenueError, RovenueResult};
6use crate::time::{Clock, SystemClock};
7use crate::transport::api::ApiEnvelope;
8use crate::transport::http_client::HttpClient;
9use crate::transport::types::HttpRequest;
10
11use super::types::{
12    CoreOffering, CoreOfferingProduct, CoreOfferings, OfferingWire, OfferingsResponse,
13};
14
15/// Cache key for the (single, project-scoped) offerings payload.
16const OFFERINGS_RESOURCE: &str = "offerings";
17
18pub struct OfferingsClient {
19    http: Arc<HttpClient>,
20    store: Arc<CacheStore>,
21    clock: Arc<dyn Clock>,
22}
23
24impl OfferingsClient {
25    pub fn new(http: Arc<HttpClient>, store: Arc<CacheStore>) -> Self {
26        Self {
27            http,
28            store,
29            clock: Arc::new(SystemClock),
30        }
31    }
32
33    pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
34        self.clock = clock;
35        self
36    }
37
38    /// Fetch offerings.
39    ///
40    /// On a successful live fetch the raw `OfferingsResponse` JSON is persisted
41    /// and the mapped result returned. If the fetch fails with a
42    /// *connectivity*-class error (`NetworkUnavailable` / `Timeout`), the last
43    /// cached offerings are served so funnel screens keep rendering offline.
44    ///
45    /// Auth (`InvalidApiKey`) and server (`ServerError`, `RateLimited`,
46    /// `Internal`) failures are propagated as before — stale offerings are NOT
47    /// served on these because they indicate a reachable-but-rejecting backend,
48    /// not loss of connectivity.
49    pub fn get_offerings(&self) -> RovenueResult<CoreOfferings> {
50        match self
51            .http
52            .get_json::<ApiEnvelope<OfferingsResponse>>(HttpRequest::new("/v1/offerings"))
53        {
54            Ok(resp) => {
55                let body = resp.body.ok_or(RovenueError::Internal())?;
56                // Persist the raw response (best-effort: a cache write failure
57                // must not fail an otherwise-successful fetch).
58                if let Ok(raw) = serde_json::to_string(&body.data) {
59                    let _ = OfferingsCacheRepo::new(&self.store).put(
60                        OFFERINGS_RESOURCE,
61                        &raw,
62                        self.clock.now_unix_ms(),
63                    );
64                }
65                Ok(map_response(body.data))
66            }
67            Err(e) if matches!(e.kind, ErrorKind::NetworkUnavailable | ErrorKind::Timeout) => {
68                // Connectivity failure: serve last-known offerings if present,
69                // otherwise propagate the original network error.
70                match OfferingsCacheRepo::new(&self.store).get(OFFERINGS_RESOURCE)? {
71                    Some(raw) => {
72                        let parsed: OfferingsResponse =
73                            serde_json::from_str(&raw).map_err(|_| RovenueError::Internal())?;
74                        Ok(map_response(parsed))
75                    }
76                    None => Err(e),
77                }
78            }
79            Err(e) => Err(e),
80        }
81    }
82}
83
84fn map_response(resp: OfferingsResponse) -> CoreOfferings {
85    let offerings: Vec<CoreOffering> = resp.offerings.into_iter().map(map_offering).collect();
86    let current = offerings
87        .iter()
88        .find(|o| o.is_default)
89        .map(|o| o.identifier.clone());
90    CoreOfferings { current, offerings }
91}
92
93/// Shared wire→FFI mapping for a single offering, reused by the placements
94/// client (a placement funnel's `offering` is the exact same
95/// `OfferingWire` shape `GET /v1/offerings` returns per-item).
96pub(crate) fn map_offering(o: OfferingWire) -> CoreOffering {
97    CoreOffering {
98        identifier: o.identifier,
99        is_default: o.is_default,
100        packages: o
101            .packages
102            .into_iter()
103            .map(|p| CoreOfferingProduct {
104                package_identifier: p.package_identifier,
105                identifier: p.identifier,
106                product_type: p.product_type,
107                display_name: p.display_name,
108                apple_product_id: p.store_ids.apple,
109                google_product_id: p.store_ids.google,
110                android_base_plan_id: p.android_base_plan_id,
111                android_offer_id: p.android_offer_id,
112            })
113            .collect(),
114    }
115}