#![allow(clippy::module_name_repetitions)]
use super::{
error::{Error::IoError, Result},
PurchaseResponse, UnityPurchaseReceipt,
};
use async_recursion::async_recursion;
use chrono::{DateTime, Utc};
use hyper::{body, Body, Client, Request};
use hyper_tls::HttpsConnector;
use serde::{Deserialize, Serialize};
const APPLE_STATUS_CODE_TEST: i32 = 21007;
const APPLE_STATUS_VALID: i32 = 0;
const APPLE_PROD_VERIFY_RECEIPT: &str = "https://buy.itunes.apple.com";
const APPLE_TEST_VERIFY_RECEIPT: &str = "https://sandbox.itunes.apple.com";
pub struct AppleUrls<'a> {
pub production: &'a str,
pub sandbox: &'a str,
}
impl Default for AppleUrls<'_> {
fn default() -> Self {
AppleUrls {
production: APPLE_PROD_VERIFY_RECEIPT,
sandbox: APPLE_TEST_VERIFY_RECEIPT,
}
}
}
#[derive(Serialize)]
pub struct AppleRequest {
#[serde(rename = "receipt-data")]
pub receipt_data: String,
pub password: String,
}
#[derive(Default, Clone, Debug, Serialize, Deserialize)]
pub struct AppleLatestReceipt {
pub quantity: Option<String>,
pub cancellation_date_ms: Option<String>,
pub cancellation_reason: Option<String>,
pub expires_date_ms: Option<String>,
pub expires_date: Option<String>,
pub original_purchase_date: Option<String>,
pub product_id: Option<String>,
pub purchase_date: Option<String>,
pub transaction_id: Option<String>,
}
#[derive(Default, Clone, Debug, Serialize, Deserialize)]
pub struct AppleResponse {
pub status: i32,
#[serde(rename = "is-retryable")]
pub is_retryable: Option<bool>,
pub environment: Option<String>,
pub latest_receipt: Option<String>,
pub latest_receipt_info: Option<Vec<AppleLatestReceipt>>,
pub receipt: Option<AppleReceipt>,
}
impl AppleResponse {
#[must_use]
pub fn is_subscription(&self, transaction_id: &str) -> bool {
transaction_id.is_empty()
|| self
.get_receipt(transaction_id)
.filter(AppleInAppReceipt::is_subscription)
.is_some()
}
#[must_use]
pub fn get_product_id(&self, transaction_id: &str) -> Option<String> {
self.get_receipt(transaction_id)
.and_then(|receipt| receipt.product_id)
}
#[must_use]
pub fn get_receipt(&self, transaction_id: &str) -> Option<AppleInAppReceipt> {
self.receipt
.as_ref()
.and_then(|receipt| receipt.get_transaction(transaction_id))
.cloned()
}
#[must_use]
pub fn get_latest_receipt(&self) -> Option<AppleInAppReceipt> {
self.receipt
.as_ref()
.and_then(AppleReceipt::get_latest_receipt)
.cloned()
}
}
#[derive(Default, Clone, Debug, Serialize, Deserialize)]
pub struct AppleReceipt {
pub in_app: Option<Vec<AppleInAppReceipt>>,
}
impl AppleReceipt {
pub fn get_transaction(&self, transaction_id: &str) -> Option<&AppleInAppReceipt> {
self.in_app.as_ref().and_then(|in_app| {
in_app
.iter()
.find(|in_app| in_app.transaction_id.as_deref() == Some(transaction_id))
})
}
pub fn get_latest_receipt(&self) -> Option<&AppleInAppReceipt> {
self.in_app.as_ref().and_then(|in_app| {
in_app.iter().max_by(|a, b| {
let a = a
.expires_date_ms
.clone()
.unwrap_or_default()
.parse::<i64>()
.unwrap_or_default();
let b = b
.expires_date_ms
.clone()
.unwrap_or_default()
.parse::<i64>()
.unwrap_or_default();
a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Less)
})
})
}
}
#[derive(Default, Clone, Debug, Serialize, Deserialize)]
pub struct AppleInAppReceipt {
pub product_id: Option<String>,
pub transaction_id: Option<String>,
pub expires_date_ms: Option<String>,
pub expires_date: Option<String>,
}
impl AppleInAppReceipt {
pub const fn is_subscription(&self) -> bool {
self.expires_date_ms.is_some()
}
}
pub async fn fetch_apple_receipt_data(
receipt: &UnityPurchaseReceipt,
password: &str,
) -> Result<AppleResponse> {
fetch_apple_receipt_data_with_urls(receipt, &AppleUrls::default(), Some(&password.to_string()))
.await
}
pub async fn fetch_apple_receipt_data_with_urls(
receipt: &UnityPurchaseReceipt,
apple_urls: &AppleUrls<'_>,
password: Option<&String>,
) -> Result<AppleResponse> {
let https = HttpsConnector::new();
let client = Client::builder().build::<_, hyper::Body>(https);
let password = password.cloned().ok_or_else(|| {
IoError(std::io::Error::new(
std::io::ErrorKind::NotFound,
"no apple secret has been set",
))
})?;
let request_body = serde_json::to_string(&AppleRequest {
receipt_data: receipt.payload.clone(),
password,
})?;
fetch_apple_response(
&client,
&request_body,
apple_urls,
&receipt.transaction_id,
true,
)
.await
}
#[allow(clippy::must_use_candidate)]
pub fn validate_apple_subscription(
response: &AppleResponse,
transaction_id: &str,
now: DateTime<Utc>,
) -> PurchaseResponse {
let (valid, product_id) = if transaction_id.is_empty() {
validate_expiration(now, response.get_latest_receipt())
} else {
let mut result = validate_expiration(now, response.get_receipt(transaction_id));
let (valid, _) = result;
if !valid {
tracing::warn!(
"Received an expired transaction_id: {}, attempting to find latest receipt",
transaction_id
);
result = validate_expiration(now, response.get_latest_receipt());
}
result
};
PurchaseResponse { valid, product_id }
}
fn validate_expiration(
now: DateTime<Utc>,
in_app_receipt: Option<AppleInAppReceipt>,
) -> (bool, Option<String>) {
in_app_receipt
.and_then(|receipt| {
receipt.expires_date_ms.as_ref().and_then(|expiry| {
expiry
.parse::<i64>()
.map(|expiry_time| {
(
expiry_time > now.timestamp_millis(),
receipt.product_id.clone(),
)
})
.ok()
})
})
.unwrap_or_default()
}
#[allow(clippy::must_use_candidate)]
pub fn validate_apple_package(response: &AppleResponse, transaction_id: &str) -> PurchaseResponse {
let product_id = response.get_product_id(transaction_id);
let valid = response.status == APPLE_STATUS_VALID && product_id.is_some();
PurchaseResponse {
valid,
product_id: response.get_product_id(transaction_id),
}
}
#[async_recursion]
async fn fetch_apple_response(
client: &Client<hyper_tls::HttpsConnector<hyper::client::HttpConnector>>,
request_body: &str,
apple_urls: &AppleUrls,
transaction_id: &str,
prod: bool,
) -> Result<AppleResponse> {
let req = Request::builder()
.method("POST")
.uri(format!(
"{}/verifyReceipt",
if prod {
&apple_urls.production
} else {
&apple_urls.sandbox
}
))
.body(Body::from(request_body.to_owned()))?;
let resp = client.request(req).await?;
let buf = body::to_bytes(resp).await?;
tracing::debug!(
"apple response: {}",
String::from_utf8_lossy(&buf).replace('\n', "")
);
let response = serde_json::from_slice::<AppleResponse>(&buf)?;
let latest_expires_date = response
.get_receipt(transaction_id)
.and_then(|receipt| receipt.expires_date);
tracing::info!(target = "apple_response",
product_id = ?response.get_product_id(transaction_id),
is_subscription = %response.is_subscription(transaction_id),
status = %&response.status,
latest_expires_date = ?latest_expires_date,
);
if response.status == APPLE_STATUS_CODE_TEST {
fetch_apple_response(client, request_body, apple_urls, transaction_id, false).await
} else {
Ok(response)
}
}