rovenue/purchases/
client.rs

1use std::sync::Arc;
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::{RovenueError, RovenueResult};
6use crate::transport::api::ApiEnvelope;
7use crate::transport::http_client::HttpClient;
8use crate::transport::types::HttpPostRequest;
9
10/// Request body for POST /v1/purchases/apple-offer-signature.
11#[derive(Debug, Serialize)]
12struct OfferSignatureBody<'a> {
13    #[serde(rename = "productId")]
14    product_id: &'a str,
15    #[serde(rename = "offerId")]
16    offer_id: &'a str,
17    #[serde(rename = "appAccountToken", skip_serializing_if = "Option::is_none")]
18    app_account_token: Option<&'a str>,
19}
20
21/// Wire model for the offer-signature response body (inside the `data` envelope).
22#[derive(Debug, Deserialize)]
23struct OfferSignatureResponse {
24    #[serde(rename = "keyIdentifier")]
25    key_identifier: String,
26    nonce: String,
27    signature: String,
28    timestamp: i64,
29}
30
31/// FFI-visible result of a successful Apple promotional-offer signature request.
32#[derive(Debug, Clone, PartialEq)]
33pub struct AppleOfferSignature {
34    pub key_identifier: String,
35    pub nonce: String,
36    pub signature: String,
37    pub timestamp: i64,
38}
39
40pub struct PurchasesClient {
41    http: Arc<HttpClient>,
42}
43
44impl PurchasesClient {
45    pub fn new(http: Arc<HttpClient>) -> Self {
46        Self { http }
47    }
48
49    /// Fetch an Apple promotional-offer signature from the backend.
50    ///
51    /// This endpoint is project-scoped (authenticated by the project API key
52    /// only) — do NOT pass a user scope or idempotency key.
53    pub fn get_apple_offer_signature(
54        &self,
55        product_id: &str,
56        offer_id: &str,
57        app_account_token: Option<&str>,
58    ) -> RovenueResult<AppleOfferSignature> {
59        let body = OfferSignatureBody {
60            product_id,
61            offer_id,
62            app_account_token,
63        };
64        let resp = self
65            .http
66            .post_json::<OfferSignatureBody<'_>, ApiEnvelope<OfferSignatureResponse>>(
67                HttpPostRequest::new("/v1/purchases/apple-offer-signature"),
68                &body,
69            )?;
70        let data = resp.body.ok_or(RovenueError::Internal())?.data;
71        Ok(AppleOfferSignature {
72            key_identifier: data.key_identifier,
73            nonce: data.nonce,
74            signature: data.signature,
75            timestamp: data.timestamp,
76        })
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use std::time::Duration;
84
85    #[test]
86    fn get_apple_offer_signature_parses_response() {
87        let mut server = mockito::Server::new();
88        let _m = server
89            .mock("POST", "/v1/purchases/apple-offer-signature")
90            .with_status(200)
91            .with_header("content-type", "application/json")
92            .with_body(
93                r#"{"data":{"keyIdentifier":"K","nonce":"n","signature":"s","timestamp":123}}"#,
94            )
95            .create();
96
97        let http = Arc::new(
98            HttpClient::new(server.url(), "pk_test".into())
99                .with_max_attempts(1)
100                .with_request_timeout(Duration::from_millis(500)),
101        );
102        let client = PurchasesClient::new(http);
103        let result = client
104            .get_apple_offer_signature("prod_id", "offer_id", None)
105            .expect("signature ok");
106
107        assert_eq!(result.key_identifier, "K");
108        assert_eq!(result.nonce, "n");
109        assert_eq!(result.signature, "s");
110        assert_eq!(result.timestamp, 123);
111    }
112
113    #[test]
114    fn get_apple_offer_signature_includes_app_account_token_when_some() {
115        let mut server = mockito::Server::new();
116        let _m = server
117            .mock("POST", "/v1/purchases/apple-offer-signature")
118            .match_body(mockito::Matcher::JsonString(
119                r#"{"productId":"prod_id","offerId":"offer_id","appAccountToken":"tok-abc"}"#
120                    .into(),
121            ))
122            .with_status(200)
123            .with_header("content-type", "application/json")
124            .with_body(
125                r#"{"data":{"keyIdentifier":"K2","nonce":"n2","signature":"s2","timestamp":456}}"#,
126            )
127            .create();
128
129        let http = Arc::new(
130            HttpClient::new(server.url(), "pk_test".into())
131                .with_max_attempts(1)
132                .with_request_timeout(Duration::from_millis(500)),
133        );
134        let client = PurchasesClient::new(http);
135        let result = client
136            .get_apple_offer_signature("prod_id", "offer_id", Some("tok-abc"))
137            .expect("signature ok");
138
139        assert_eq!(result.key_identifier, "K2");
140        assert_eq!(result.timestamp, 456);
141    }
142
143    #[test]
144    fn get_apple_offer_signature_omits_app_account_token_when_none() {
145        let mut server = mockito::Server::new();
146        let _m = server
147            .mock("POST", "/v1/purchases/apple-offer-signature")
148            .match_body(mockito::Matcher::JsonString(
149                r#"{"productId":"prod_id","offerId":"offer_id"}"#.into(),
150            ))
151            .with_status(200)
152            .with_header("content-type", "application/json")
153            .with_body(
154                r#"{"data":{"keyIdentifier":"K3","nonce":"n3","signature":"s3","timestamp":789}}"#,
155            )
156            .create();
157
158        let http = Arc::new(
159            HttpClient::new(server.url(), "pk_test".into())
160                .with_max_attempts(1)
161                .with_request_timeout(Duration::from_millis(500)),
162        );
163        let client = PurchasesClient::new(http);
164        let result = client
165            .get_apple_offer_signature("prod_id", "offer_id", None)
166            .expect("signature ok");
167
168        assert_eq!(result.key_identifier, "K3");
169        assert_eq!(result.timestamp, 789);
170    }
171}