use async_trait::async_trait;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use smbcloud_ascapi_core::jsonapi::{path_and_query, Links};
use smbcloud_ascapi_core::Client;
use smbcloud_ascapi_core::Result;
use std::collections::HashMap;
pub const RESOURCE_TYPE: &str = "appPrices";
const PAGE_LIMIT: &str = "200";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PriceKind {
Manual,
Automatic,
}
impl PriceKind {
fn path_segment(self) -> &'static str {
match self {
PriceKind::Manual => "manualPrices",
PriceKind::Automatic => "automaticPrices",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TerritoryPrice {
pub territory: String,
pub currency: Option<String>,
pub customer_price: Option<String>,
pub proceeds: Option<String>,
pub manual: bool,
pub start_date: Option<String>,
pub end_date: Option<String>,
pub price_point_id: Option<String>,
pub app_price_id: String,
}
#[async_trait]
pub trait AppPricesApi {
async fn list_app_prices(
&self,
app_id: &str,
kind: PriceKind,
territory: Option<&str>,
) -> Result<Vec<TerritoryPrice>>;
}
#[async_trait]
impl AppPricesApi for Client {
async fn list_app_prices(
&self,
app_id: &str,
kind: PriceKind,
territory: Option<&str>,
) -> Result<Vec<TerritoryPrice>> {
let mut query = vec![
("include", "appPricePoint,territory"),
("limit", PAGE_LIMIT),
];
if let Some(code) = territory {
query.push(("filter[territory]", code));
}
let mut path = format!("/v1/appPriceSchedules/{app_id}/{}", kind.path_segment());
let mut query_for_this_page: &[(&str, &str)] = &query;
let mut prices = Vec::new();
loop {
let doc: PricesDocument = self
.request(Method::GET, &path, query_for_this_page, None::<&()>)
.await?;
prices.extend(join(&doc));
let Some(next) = doc.links.and_then(|links| links.next) else {
break;
};
let Some(next_path) = path_and_query(&next) else {
break;
};
path = next_path;
query_for_this_page = &[];
}
prices.sort_by(|a, b| a.territory.cmp(&b.territory));
Ok(prices)
}
}
fn join(doc: &PricesDocument) -> Vec<TerritoryPrice> {
let mut points: HashMap<&str, &PricePointAttributes> = HashMap::new();
let mut currencies: HashMap<&str, Option<&str>> = HashMap::new();
for resource in &doc.included {
match resource {
IncludedResource::AppPricePoint { id, attributes } => {
points.insert(id.as_str(), attributes);
}
IncludedResource::Territory { id, attributes } => {
currencies.insert(id.as_str(), attributes.currency.as_deref());
}
IncludedResource::Unknown => {}
}
}
doc.data
.iter()
.map(|price| {
let point_id = price
.relationships
.app_price_point
.as_ref()
.and_then(|rel| rel.data.as_ref())
.map(|reference| reference.id.clone());
let territory = price
.relationships
.territory
.as_ref()
.and_then(|rel| rel.data.as_ref())
.map(|reference| reference.id.clone())
.unwrap_or_default();
let point = point_id.as_deref().and_then(|id| points.get(id)).copied();
TerritoryPrice {
currency: currencies
.get(territory.as_str())
.copied()
.flatten()
.map(str::to_string),
customer_price: point.and_then(|p| p.customer_price.clone()),
proceeds: point.and_then(|p| p.proceeds.clone()),
manual: price.attributes.manual.unwrap_or(false),
start_date: price.attributes.start_date.clone(),
end_date: price.attributes.end_date.clone(),
price_point_id: point_id,
app_price_id: price.id.clone(),
territory,
}
})
.collect()
}
#[derive(Debug, Clone, Deserialize)]
struct PricesDocument {
data: Vec<PriceResource>,
#[serde(default)]
included: Vec<IncludedResource>,
#[serde(default)]
links: Option<Links>,
}
#[derive(Debug, Clone, Deserialize)]
struct PriceResource {
id: String,
#[serde(default)]
attributes: PriceAttributes,
#[serde(default)]
relationships: PriceRelationships,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PriceAttributes {
#[serde(default)]
manual: Option<bool>,
#[serde(default)]
start_date: Option<String>,
#[serde(default)]
end_date: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PriceRelationships {
#[serde(default)]
app_price_point: Option<ToOne>,
#[serde(default)]
territory: Option<ToOne>,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct ToOne {
#[serde(default)]
pub(crate) data: Option<Reference>,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct Reference {
pub(crate) id: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type")]
enum IncludedResource {
#[serde(rename = "appPricePoints")]
AppPricePoint {
id: String,
attributes: PricePointAttributes,
},
#[serde(rename = "territories")]
Territory {
id: String,
attributes: TerritoryAttributes,
},
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PricePointAttributes {
#[serde(default)]
customer_price: Option<String>,
#[serde(default)]
proceeds: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct TerritoryAttributes {
#[serde(default)]
currency: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
const MANUAL_PAGE: &str = r#"{
"data": [{
"type": "appPrices",
"id": "eyJzIjoiNjc1MTE0MzIzNyJ9",
"attributes": { "manual": true, "startDate": null, "endDate": null },
"relationships": {
"appPricePoint": { "data": { "type": "appPricePoints", "id": "point-usa" } },
"territory": { "data": { "type": "territories", "id": "USA" } }
}
}],
"included": [
{ "type": "appPricePoints", "id": "point-usa",
"attributes": { "customerPrice": "7.99", "proceeds": "6.79" },
"relationships": { "territory": {} } },
{ "type": "territories", "id": "USA", "attributes": { "currency": "USD" } }
],
"links": { "self": "https://api.appstoreconnect.apple.com/v1/x" }
}"#;
#[test]
fn joins_price_point_and_territory_onto_the_price() {
let doc: PricesDocument = serde_json::from_str(MANUAL_PAGE).unwrap();
let prices = join(&doc);
assert_eq!(prices.len(), 1);
let price = &prices[0];
assert_eq!(price.territory, "USA");
assert_eq!(price.currency.as_deref(), Some("USD"));
assert_eq!(price.customer_price.as_deref(), Some("7.99"));
assert_eq!(price.proceeds.as_deref(), Some("6.79"));
assert!(price.manual);
assert_eq!(price.end_date, None);
}
#[test]
fn tolerates_an_included_type_it_does_not_know() {
let body = MANUAL_PAGE.replace(
r#"{ "type": "territories", "id": "USA", "attributes": { "currency": "USD" } }"#,
r#"{ "type": "territories", "id": "USA", "attributes": { "currency": "USD" } },
{ "type": "somethingNew", "id": "z", "attributes": { "whatever": 1 } }"#,
);
let doc: PricesDocument = serde_json::from_str(&body).unwrap();
assert_eq!(join(&doc)[0].customer_price.as_deref(), Some("7.99"));
}
#[test]
fn keeps_scheduled_end_dates_on_automatic_prices() {
let body = MANUAL_PAGE
.replace(r#""manual": true"#, r#""manual": false"#)
.replace(r#""endDate": null"#, r#""endDate": "2026-09-14""#);
let doc: PricesDocument = serde_json::from_str(&body).unwrap();
let price = &join(&doc)[0];
assert!(!price.manual);
assert_eq!(price.end_date.as_deref(), Some("2026-09-14"));
}
#[test]
fn a_next_link_is_absent_on_the_last_page() {
let doc: PricesDocument = serde_json::from_str(MANUAL_PAGE).unwrap();
assert!(doc.links.expect("links present").next.is_none());
let with_next = MANUAL_PAGE.replace(
r#""links": { "self": "https://api.appstoreconnect.apple.com/v1/x" }"#,
r#""links": { "next": "https://api.appstoreconnect.apple.com/v1/y?cursor=AQ" }"#,
);
let doc: PricesDocument = serde_json::from_str(&with_next).unwrap();
assert_eq!(
path_and_query(&doc.links.unwrap().next.unwrap()).as_deref(),
Some("/v1/y?cursor=AQ")
);
}
}