rovenue/receipts/
client.rs1use std::sync::Arc;
2
3use crate::error::{RovenueError, RovenueResult};
4use crate::placements::CorePresentedContext;
5use crate::transport::api::ApiEnvelope;
6use crate::transport::http_client::HttpClient;
7use crate::transport::types::HttpPostRequest;
8
9use super::types::{PresentedContextWire, ReceiptBody, ReceiptPostOutcome, ReceiptResponse};
10
11pub struct ReceiptClient {
12 http: Arc<HttpClient>,
13}
14
15impl ReceiptClient {
16 pub fn new(http: Arc<HttpClient>) -> Self {
17 Self { http }
18 }
19
20 #[allow(clippy::too_many_arguments)]
21 pub fn post_apple(
22 &self,
23 receipt: &str,
24 app_user_id: &str,
25 product_id: &str,
26 idempotency_key: &str,
27 app_account_token: Option<&str>,
28 presented_context: Option<&CorePresentedContext>,
29 ) -> RovenueResult<ReceiptPostOutcome> {
30 self.post(
31 "/v1/receipts/apple",
32 receipt,
33 app_user_id,
34 product_id,
35 idempotency_key,
36 app_account_token,
37 None,
38 None,
39 presented_context,
40 )
41 }
42
43 #[allow(clippy::too_many_arguments)]
44 pub fn post_google(
45 &self,
46 receipt: &str,
47 app_user_id: &str,
48 product_id: &str,
49 idempotency_key: &str,
50 obfuscated_account_id: Option<&str>,
51 obfuscated_profile_id: Option<&str>,
52 presented_context: Option<&CorePresentedContext>,
53 ) -> RovenueResult<ReceiptPostOutcome> {
54 self.post(
55 "/v1/receipts/google",
56 receipt,
57 app_user_id,
58 product_id,
59 idempotency_key,
60 None,
61 obfuscated_account_id,
62 obfuscated_profile_id,
63 presented_context,
64 )
65 }
66
67 #[allow(clippy::too_many_arguments)]
68 fn post(
69 &self,
70 path: &str,
71 receipt: &str,
72 app_user_id: &str,
73 product_id: &str,
74 idempotency_key: &str,
75 app_account_token: Option<&str>,
76 obfuscated_account_id: Option<&str>,
77 obfuscated_profile_id: Option<&str>,
78 presented_context: Option<&CorePresentedContext>,
79 ) -> RovenueResult<ReceiptPostOutcome> {
80 let presented_context_wire = presented_context.map(|c| PresentedContextWire {
81 placement_id: &c.placement_id,
82 funnel_id: &c.funnel_id,
83 variant_id: c.variant_id.as_deref(),
84 experiment_key: c.experiment_key.as_deref(),
85 });
86 let body = ReceiptBody {
87 receipt,
88 app_user_id,
89 product_id,
90 app_account_token,
91 obfuscated_account_id,
92 obfuscated_profile_id,
93 presented_context: presented_context_wire,
94 };
95 let resp = self
96 .http
97 .post_json::<ReceiptBody<'_>, ApiEnvelope<ReceiptResponse>>(
98 HttpPostRequest::new(path)
99 .user_scope(app_user_id)
100 .idempotency_key(idempotency_key),
101 &body,
102 )?;
103 let data = resp.body.ok_or(RovenueError::Internal())?.data;
104 Ok(ReceiptPostOutcome {
105 subscriber_id: data.subscriber.id,
106 app_user_id: data.subscriber.app_user_id,
107 virtual_currencies: data.virtual_currency_balances,
108 access: data.access,
109 product_type: data.product.map(|p| p.product_type),
110 })
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use std::sync::Arc;
118 use std::time::Duration;
119
120 #[test]
121 fn post_apple_parses_access_map() {
122 let mut server = mockito::Server::new();
123 let _m = server
124 .mock("POST", "/v1/receipts/apple")
125 .with_status(200)
126 .with_header("content-type", "application/json")
127 .with_body(
128 r#"{"data":{"subscriber":{"id":"sub_1","appUserId":"u1"},
129 "virtualCurrencyBalances":{"gems":42},
130 "access":{"pro":{"isActive":true,"expiresDate":null,
131 "store":"APP_STORE","productIdentifier":"pro_monthly"}}}}"#,
132 )
133 .create();
134
135 let http = Arc::new(
136 HttpClient::new(server.url(), "pk_test".into())
137 .with_max_attempts(1)
138 .with_request_timeout(Duration::from_millis(500)),
139 );
140 let client = ReceiptClient::new(http);
141 let outcome = client
142 .post_apple("rcpt", "u1", "pro_monthly", "idem_rcpt_x", None, None)
143 .expect("post ok");
144 assert_eq!(outcome.subscriber_id, "sub_1");
145 let access = outcome.access.expect("access present");
146 assert!(access.get("pro").unwrap().is_active);
147 }
148}