1use std::sync::mpsc::{channel, Receiver, TryRecvError};
19
20use serde::Deserialize;
21use serde_json::Value;
22
23#[derive(Debug, Clone, PartialEq)]
27pub struct Offer {
28 pub item: String,
30 pub name: String,
32 pub price_label: Option<String>,
35 pub price_ref: String,
37 pub world_id: String,
39}
40
41pub fn offer_from(item: &str, price_ref: &Value, data: &Value, world_id: &str) -> Offer {
45 let name = data
46 .get("name")
47 .and_then(Value::as_str)
48 .filter(|s| !s.is_empty())
49 .unwrap_or(item)
50 .to_string();
51 let price_label =
52 data.get("price")
53 .map(|p| match data.get("currency").and_then(Value::as_str) {
54 Some(c) => format!("{p} {c}"),
55 None => p.to_string(),
56 });
57 let price_ref = price_ref_string(price_ref)
58 .or_else(|| {
59 data.get("priceRef")
60 .and_then(Value::as_str)
61 .map(str::to_string)
62 })
63 .unwrap_or_else(|| item.to_string());
64 Offer {
65 item: item.to_string(),
66 name,
67 price_label,
68 price_ref,
69 world_id: world_id.to_string(),
70 }
71}
72
73fn price_ref_string(v: &Value) -> Option<String> {
76 if let Some(s) = v.as_str() {
77 return (!s.is_empty()).then(|| s.to_string());
78 }
79 for key in ["priceRef", "productKey", "key", "ref"] {
80 if let Some(s) = v.get(key).and_then(Value::as_str) {
81 if !s.is_empty() {
82 return Some(s.to_string());
83 }
84 }
85 }
86 None
87}
88
89#[derive(Debug, Clone, Default, Deserialize)]
91pub struct Receipt {
92 #[serde(default)]
93 pub grant: Grant,
94 #[serde(default)]
95 pub charge: Charge,
96}
97
98#[derive(Debug, Clone, Default, Deserialize)]
99pub struct Grant {
100 #[serde(default, rename = "itemStructuredId")]
101 pub item: String,
102 #[serde(default)]
103 pub granted: bool,
104 #[serde(default)]
107 pub persisted: bool,
108 #[serde(default, rename = "ref")]
110 pub reference: String,
111}
112
113#[derive(Debug, Clone, Default, Deserialize)]
114pub struct Charge {
115 #[serde(default)]
117 pub amount: f64,
118 #[serde(default)]
119 pub currency: String,
120 #[serde(default)]
121 pub recorded: bool,
122 #[serde(default)]
125 pub duplicate: bool,
126}
127
128#[derive(Debug, Clone, Default, Deserialize)]
131pub struct Entitlement {
132 #[serde(default)]
133 pub plus: bool,
134 #[serde(default)]
135 pub tier: String,
136 #[serde(default)]
137 pub source: String,
138 #[serde(default, rename = "ownsGame")]
139 pub owns_game: bool,
140 #[serde(default)]
141 pub credits: f64,
142}
143
144pub struct CommerceClient {
147 base: String,
148 token: Option<String>,
151 pending: Option<(Offer, Receiver<Result<Receipt, String>>)>,
152 ent_pending: Option<Receiver<Result<Entitlement, String>>>,
153}
154
155impl CommerceClient {
156 pub fn new(base: impl Into<String>) -> Self {
157 Self {
158 base: base.into(),
159 token: None,
160 pending: None,
161 ent_pending: None,
162 }
163 }
164
165 pub fn from_env() -> Self {
167 let base = std::env::var("INFINITE_COMMERCE_BASE")
168 .unwrap_or_else(|_| "https://api.pixygon.io/v1".to_string());
169 Self::new(base)
170 }
171
172 pub fn with_token(mut self, token: Option<String>) -> Self {
175 self.token = token.filter(|t| !t.trim().is_empty());
176 self
177 }
178
179 pub fn has_token(&self) -> bool {
181 self.token.is_some()
182 }
183
184 pub fn in_flight(&self) -> Option<&Offer> {
186 self.pending.as_ref().map(|(o, _)| o)
187 }
188
189 pub fn buy(&mut self, offer: Offer) -> bool {
192 let Some(token) = self.token.clone() else {
193 return false;
194 };
195 if self.pending.is_some() {
196 return false;
197 }
198 let url = format!("{}/thread/purchase", self.base.trim_end_matches('/'));
199 let body = serde_json::json!({
200 "worldId": offer.world_id,
201 "itemStructuredId": offer.item,
202 "priceRef": offer.price_ref,
203 });
204 let (tx, rx) = channel();
205 std::thread::spawn(move || {
206 let _ = tx.send(post_blocking(&url, &token, &body));
207 });
208 self.pending = Some((offer, rx));
209 true
210 }
211
212 pub fn poll(&mut self) -> Option<(Offer, Result<Receipt, String>)> {
214 let (_, rx) = self.pending.as_ref()?;
215 let result = match rx.try_recv() {
216 Ok(r) => r,
217 Err(TryRecvError::Empty) => return None,
218 Err(TryRecvError::Disconnected) => Err("purchase thread stopped".into()),
219 };
220 let (offer, _) = self.pending.take()?;
221 Some((offer, result))
222 }
223
224 pub fn entitlements(&mut self, project_id: &str) {
227 let Some(token) = self.token.clone() else {
228 return;
229 };
230 if self.ent_pending.is_some() || project_id.trim().is_empty() {
231 return;
232 }
233 let url = format!(
234 "{}/entitlements/{}",
235 self.base.trim_end_matches('/'),
236 project_id.trim()
237 );
238 let (tx, rx) = channel();
239 std::thread::spawn(move || {
240 let _ = tx.send(get_blocking(&url, &token));
241 });
242 self.ent_pending = Some(rx);
243 }
244
245 pub fn poll_entitlements(&mut self) -> Option<Result<Entitlement, String>> {
247 let rx = self.ent_pending.as_ref()?;
248 let result = match rx.try_recv() {
249 Ok(r) => r,
250 Err(TryRecvError::Empty) => return None,
251 Err(TryRecvError::Disconnected) => Err("entitlements thread stopped".into()),
252 };
253 self.ent_pending = None;
254 Some(result)
255 }
256}
257
258fn error_message(body: &str) -> Option<String> {
260 #[derive(Deserialize)]
261 struct Err_ {
262 error: String,
263 }
264 serde_json::from_str::<Err_>(body).ok().map(|e| e.error)
265}
266
267fn post_blocking(url: &str, token: &str, body: &Value) -> Result<Receipt, String> {
270 let rt = tokio::runtime::Builder::new_current_thread()
271 .enable_all()
272 .build()
273 .map_err(|e| e.to_string())?;
274 rt.block_on(async {
275 let resp = reqwest::Client::new()
276 .post(url)
277 .bearer_auth(token)
278 .json(body)
279 .send()
280 .await
281 .map_err(|e| e.to_string())?;
282 let status = resp.status();
283 let text = resp.text().await.map_err(|e| e.to_string())?;
284 if !status.is_success() {
285 return Err(error_message(&text).unwrap_or_else(|| format!("HTTP {}", status.as_u16())));
286 }
287 serde_json::from_str(&text).map_err(|e| e.to_string())
288 })
289}
290
291fn get_blocking(url: &str, token: &str) -> Result<Entitlement, String> {
293 let rt = tokio::runtime::Builder::new_current_thread()
294 .enable_all()
295 .build()
296 .map_err(|e| e.to_string())?;
297 rt.block_on(async {
298 let resp = reqwest::Client::new()
299 .get(url)
300 .bearer_auth(token)
301 .send()
302 .await
303 .map_err(|e| e.to_string())?;
304 let status = resp.status();
305 let text = resp.text().await.map_err(|e| e.to_string())?;
306 if !status.is_success() {
307 return Err(error_message(&text).unwrap_or_else(|| format!("HTTP {}", status.as_u16())));
308 }
309 serde_json::from_str(&text).map_err(|e| e.to_string())
310 })
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn offers_read_market_stall_data() {
319 let data = serde_json::json!({
321 "item": "21010001", "name": "Veilwalker Blade", "price": 250, "currency": "gold"
322 });
323 let o = offer_from("21010001", &Value::Null, &data, "45010001");
324 assert_eq!(o.name, "Veilwalker Blade");
325 assert_eq!(o.price_label.as_deref(), Some("250 gold"));
326 assert_eq!(o.price_ref, "21010001");
328 assert_eq!(o.world_id, "45010001");
329 }
330
331 #[test]
332 fn price_ref_resolution_order() {
333 let o = offer_from(
335 "21010001",
336 &serde_json::json!("blade-sku"),
337 &Value::Null,
338 "w",
339 );
340 assert_eq!(o.price_ref, "blade-sku");
341 let o = offer_from(
343 "21010001",
344 &serde_json::json!({"productKey": "blade-pk"}),
345 &Value::Null,
346 "w",
347 );
348 assert_eq!(o.price_ref, "blade-pk");
349 let data = serde_json::json!({"priceRef": "from-data"});
351 let o = offer_from("21010001", &Value::Null, &data, "w");
352 assert_eq!(o.price_ref, "from-data");
353 let o = offer_from("21010001", &Value::Null, &Value::Null, "w");
355 assert_eq!(o.name, "21010001");
356 assert!(o.price_label.is_none());
357 }
358
359 #[test]
360 fn parses_the_purchase_response_shape() {
361 let json = r#"{
363 "grant": { "itemStructuredId": "21010001", "worldId": "45010001",
364 "userId": "u1", "granted": true, "persisted": false,
365 "ref": "thread:u1:45010001:21010001" },
366 "charge": { "amount": 25, "currency": "NOK", "recorded": true, "duplicate": false }
367 }"#;
368 let r: Receipt = serde_json::from_str(json).unwrap();
369 assert!(r.grant.granted);
370 assert!(!r.grant.persisted);
371 assert_eq!(r.grant.reference, "thread:u1:45010001:21010001");
372 assert_eq!(r.charge.amount, 25.0);
373 assert_eq!(r.charge.currency, "NOK");
374 assert!(r.charge.recorded);
375 }
376
377 #[test]
378 fn parses_the_entitlement_shape() {
379 let json = r#"{
381 "projectId": "p1", "projectSlug": "infinite", "userId": "u1",
382 "plus": true, "tier": "premium", "source": "subscription",
383 "ownsGame": true, "credits": 120, "purchasedCredits": 100,
384 "features": {}, "checkedAt": "2026-07-14T12:00:00Z"
385 }"#;
386 let e: Entitlement = serde_json::from_str(json).unwrap();
387 assert!(e.plus);
388 assert_eq!(e.tier, "premium");
389 assert!(e.owns_game);
390 assert_eq!(e.credits, 120.0);
391 }
392
393 #[test]
394 fn surfaces_the_server_error_body() {
395 assert_eq!(
396 error_message(
397 r#"{"error":"No server-side price for priceRef \"x\" in this world's project."}"#
398 )
399 .as_deref(),
400 Some("No server-side price for priceRef \"x\" in this world's project.")
401 );
402 assert!(error_message("not json").is_none());
403 }
404
405 #[test]
406 fn buying_requires_a_passport_and_one_at_a_time() {
407 let offer = offer_from("21010001", &Value::Null, &Value::Null, "w");
408 let mut anon = CommerceClient::new("http://x").with_token(Some(" ".into()));
410 assert!(!anon.has_token());
411 assert!(!anon.buy(offer.clone()));
412 assert!(anon.in_flight().is_none());
413 let mut c = CommerceClient::new("http://127.0.0.1:9").with_token(Some("tok".into()));
415 assert!(c.buy(offer.clone()));
416 assert!(c.in_flight().is_some());
417 assert!(!c.buy(offer));
418 }
419
420 #[test]
421 fn entitlements_reads_need_a_token_and_a_project() {
422 let mut anon = CommerceClient::new("http://x");
423 anon.entitlements("p1");
424 assert!(anon.poll_entitlements().is_none()); let mut c = CommerceClient::new("http://x").with_token(Some("tok".into()));
426 c.entitlements(" ");
427 assert!(c.ent_pending.is_none()); }
429}